diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/Library.tscn b/Library.tscn new file mode 100644 index 0000000..79abcde --- /dev/null +++ b/Library.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://1oww0utk77w7"] + +[ext_resource type="Script" uid="uid://hkh1ewsuji8m" path="res://library.gd" id="1_gqcys"] + +[node name="Library" type="Node"] +script = ExtResource("1_gqcys") diff --git a/Main.tscn b/Main.tscn new file mode 100644 index 0000000..227d678 --- /dev/null +++ b/Main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3 uid="uid://cd60nfxe4lnq1"] + +[ext_resource type="Script" uid="uid://cus8nh0g3yyj2" path="res://main.gd" id="1_glv2v"] + +[node name="Main" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_glv2v") diff --git a/README.md b/README.md index 6e732b6..766f804 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# FabsocBot \ No newline at end of file +# FabsocBot diff --git a/addons/discord_gd/LICENSE.md b/addons/discord_gd/LICENSE.md new file mode 100644 index 0000000..f67213a --- /dev/null +++ b/addons/discord_gd/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-present Delano Lourenco + +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. \ No newline at end of file diff --git a/addons/discord_gd/README.md b/addons/discord_gd/README.md new file mode 100644 index 0000000..0824885 --- /dev/null +++ b/addons/discord_gd/README.md @@ -0,0 +1,111 @@ +Discord.gd +========================================= +###### (Get it from Godot Asset Library - https://godotengine.org/asset-library/asset/1010) + + +### A Godot plugin to interact with the Discord Bot API. Make Discord Bots in Godot! + +> 100% GDScript + +
+Godot4 + +![Make Discord bots in Godot image](https://raw.githubusercontent.com/3ddelano/discord.gd/refs/heads/main/discord_gd_thumbnail.jpg) + + +#### Godot version compatibility + +- Godot 4.x - [main branch](https://github.com/3ddelano/discord.gd/tree/main) +- Godot 3.x - [godot3 branch](https://github.com/3ddelano/discord.gd/tree/godot3) + +Features +-------------- + +- Make a Discord Bot in less than 10 lines of code +- Supports `Buttons` and `SelectMenus` +- Supports `Application Commands` aka `Slash Commands` +- Uses Godot signals to emit events like `bot_ready`, `guild_create`, `message_create`, `message_delete`, etc. +- Get User Avatar and Guild Icon as Godot's `ImageTexture` +- Uses coroutine async functions i.e Promises + + +## [🚀 Check out out GDAI MCP from the creator of Discord.gd](https://gdaimcp.com?ref=discordgd-readme) + + + + +Supercharge your Godot 4.2+ workflow with GDAI MCP – the ultimate Godot MCP server that lets AI tools like Claude, Cursor, Windsurf, VSCode and more automate scene creation, node editing, reading godot errors, creating scripts, debugging, and more. + +Vibe code like never before! + +### 🔗 **[https://gdaimcp.com](https://gdaimcp.com?ref=discordgd-readme)** + + +Installation +-------------- + +This is a regular plugin for Godot. +Copy the contents of `addons/discord_gd` into the `addons/` folder in the same directory as your project, and activate it in your project settings. + +The plugin now comes with no extra assets to stay lightweight. +If you want to try an example scene, you can see the examples from: [Discord.gd Examples](https://github.com/3ddelano/discord_gd_examples) + +> For in-depth installation instructions check the [Installation Wiki](https://3ddelano.github.io/discord.gd/installation) + +> Note: You will need a valid Discord Bot token available at [Discord Applications](https://discord.com/developers/applications) + + +Getting Started +---------- + +1. After activating the plugin. There will be a new `DiscordBot` node added to Godot. +Click on any node in the scene tree of your scene for example `Root` and add the `DiscordBot` node as a child. + +2. Connect the various signals (`bot_ready`, `guild_create`, `message_create`, `message_delete`, etc) of the `DiscordBot` node to the parent node, either through the editor or in the script using the `connect()` method. + +3. Attach a script to the `Root` node. + +```GDScript +extends Node2D + +func _ready(): + var discord_bot = $DiscordBot + discord_bot.TOKEN = "your_bot_token_here" + discord_bot.login() + discord_bot.bot_ready.connect(_on_DiscordBot_bot_ready) + discord_bot.message_create.connect(_on_DiscordBot_message_create) + +func _on_DiscordBot_bot_ready(bot: DiscordBot): + print("Logged in as %s#%s" % [bot.user.username, bot.user.discriminator]) + print("Listening on %d channels and %d guilds." % [bot.channels.size(), bot.guilds.size()]) + +func _on_DiscordBot_message_create(bot: DiscordBot, msg: Message, channel: Dictionary): + print("New message from %s: %s" % [msg.author.username, msg.content]) + + if msg.author.bot: + return + + await bot.reply(msg, "Hi!") +``` + +[Documentation](https://3ddelano.github.io/discord.gd) +---------- + + +Contributing +----------- + +This plugin is a non-profit project developped by voluntary contributors. + +### Supporters + +``` +- YaBoyTwiz#6733 +``` + +### Support the project development +Buy Me A Coffee + +Want to support in other ways? Contact me on Discord: `@3ddelano#6033` + +For doubts / help / bugs / problems / suggestions do join: [3ddelano Cafe](https://discord.gg/FZY9TqW) diff --git a/addons/discord_gd/classes/application_command.gd b/addons/discord_gd/classes/application_command.gd new file mode 100644 index 0000000..d809361 --- /dev/null +++ b/addons/discord_gd/classes/application_command.gd @@ -0,0 +1,211 @@ +class_name ApplicationCommand +""" +Represents a Discord application command. +""" + +enum COMMAND_TYPES { + __, + CHAT_INPUT, + USER, + MESSAGE +} + +const _COMMAND_TYPES = { + 1: 'CHAT_INPUT', + 2: 'USER', + 3: 'MESSAGE' +} + +enum OPTION_TYPES { + __, + SUB_COMMAND, + SUB_COMMAND_GROUP, + STRING, + INTEGER, + BOOLEAN, + USER, + CHANNEL, + ROLE, + MENTIONABLE, + NUMBER +} + +const _OPTION_TYPES = { + 1: 'SUB_COMMAND', + 2: 'SUB_COMMAND_GROUP', + 3: 'STRING', + 4: 'INTEGER', + 5: 'BOOLEAN', + 6: 'COMMAND', + 7: 'CHANNEL', + 8: 'ROLE', + 9: 'MENTIONABLE', + 10: 'NUMBER', +} + +const _CHANNEL_TYPES = { + 'GUILD_TEXT': 0, + 'DM': 1, + 'GUILD_VOICE': 2, + 'GROUP_DM': 3, + 'GUILD_CATEGORY': 4, + 'GUILD_NEWS': 5, + 'GUILD_STORE': 6, + 'GUILD_NEWS_THREAD': 10, + 'GUILD_PUBLIC_THREAD': 11, + 'GUILD_PRIVATE_THREAD': 12, + 'GUILD_STAGE_VOICE': 13 +} + +var id: String: get = get_id +var type: int = 1 +var application_id: String: get = get_application_id +var guild_id: String: get = get_guild_id +var name: String: set = set_name, get = get_name +var description: String: set = set_description, get = get_description + +var options: Array: set = set_options, get = get_options +var default_permission: bool = true +var version: String + +func get_id() -> String: + return id + +func set_type(p_type: String): + type = COMMAND_TYPES[p_type] + return self + +func get_type(): + return _OPTION_TYPES[type] + +func get_application_id() -> String: + return application_id + +func set_name(new_name: String): + name = new_name + return self + +func get_name() -> String: + return name + +func set_description(new_description: String): + description = new_description + return self + +func get_description() -> String: + return description + +func get_guild_id() -> String: + return guild_id + +func set_options(new_options: Array): + options = new_options + return self + +func get_options() -> Array: + return options + +func add_option(option_data: Dictionary) -> ApplicationCommand: + # Generic method to add an option to the command + assert(option_data.has('type'), 'ApplicationCommand option must have a type') + assert(option_data.has('name') and Helpers.is_valid_str(option_data.name), 'ApplicationCommand option must have a name') + assert(option_data.has('description') and Helpers.is_valid_str(option_data.description), 'ApplicationCommand option must have a description') + options.append(option_data) + return self + +static func sub_command_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.SUB_COMMAND, name, description, data) + +static func sub_command_group_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.SUB_COMMAND_GROUP, name, description, data) + +static func string_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.STRING, name, description, data) + +static func integer_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.INTEGER, name, description, data) + +static func boolean_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.BOOLEAN, name, description, data) + +static func user_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.USER, name, description, data) + +static func channel_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.CHANNEL, name, description, data) + +static func role_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.ROLE, name, description, data) + +static func mentionable_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.MENTIONABLE, name, description, data) + +static func number_option(name: String, description: String, data: Dictionary = {}) -> Dictionary: + return _make_option(OPTION_TYPES.NUMBER, name, description, data) + +static func choice(name: String, value) -> Dictionary: + return { + 'name': name, + 'value': value + } + +static func _make_option(type: int, name: String, description: String, data: Dictionary = {}) -> Dictionary: + if data.has('channel_types'): + for i in range(len(data.channel_types)): + if _CHANNEL_TYPES.has(data.channel_types[i]): + data.channel_types[i] = _CHANNEL_TYPES[data.channel_types[i]] + + return { + 'type': type, + 'name': name, + 'description': description, + # Optional data + 'required': data.required if data.has('required') else null, + 'choices': data.choices if data.has('choices') else null, + 'options': data.options if data.has('options') else null, + 'channel_types': data.channel_types if data.has('channel_types') else null, + 'min_value': data.min_value if data.has('min_value') else null, + 'max_value': data.max_value if data.has('max_value') else null, + 'autocomplete': data.autocomplete if data.has('autocomplete') else false + } + +func _init(data: Dictionary = {}): + id = data.id if data.has('id') else '' + type = data.type if data.has('type') else 1 + application_id = data.application_id if data.has('application_id') else '' + + guild_id = data.guild_id if data.has('guild_id') else '' + name = data.name if data.has('name') else '' + description = data.description if data.has('description') else '' + options = data.options if data.has('options') else [] + default_permission = data.default_permission if data.has('default_permission') else true + version = data.version if data.has('version') else '' + + +func _to_string(pretty: bool = false) -> String: + return JSON.stringify(_to_dict(), '\t') if pretty else JSON.stringify(_to_dict()) + +func print(): + print(_to_string(true)) + +func _to_dict(is_register = false) -> Dictionary: + if is_register: + return { + 'name': name, + 'type': type, + 'description': description, + 'default_permission': default_permission, + 'options': options + } + + return { + 'id': id, + 'type': type, + 'application_id': application_id, + 'guild_id': '', + 'name': name, + 'description': description, + 'options': options, + 'default_permission': default_permission, + 'version': version + } diff --git a/addons/discord_gd/classes/application_command.gd.uid b/addons/discord_gd/classes/application_command.gd.uid new file mode 100644 index 0000000..77cd648 --- /dev/null +++ b/addons/discord_gd/classes/application_command.gd.uid @@ -0,0 +1 @@ +uid://cpk5b2jequ6my diff --git a/addons/discord_gd/classes/bit_field.gd b/addons/discord_gd/classes/bit_field.gd new file mode 100644 index 0000000..e4e2c13 --- /dev/null +++ b/addons/discord_gd/classes/bit_field.gd @@ -0,0 +1,126 @@ +class_name BitField +""" +Helper class for bit operations. +""" + +var default_bit = 0 +var FLAGS = {} + +var bitfield: int + + +func any(bit): + return (bitfield & resolve(bit)) != default_bit + +func equals(bit): + return bitfield == resolve(bit) + +func has(bit): + bit = resolve(bit) + return (bitfield & bit) == bit + +func missing(bits): + pass + +func add(bits): + if not typeof(bits) == TYPE_ARRAY: + bits = [bits] + var total = default_bit + for bit in bits: + total |= resolve(bit) + bitfield |= total + return self + +func remove(bits): + + if typeof(bits) == TYPE_OBJECT and bits.is_class(self.get_class()): + bits = bits.bitfield + + if not typeof(bits) == TYPE_ARRAY: + bits = [bits] + + var total = default_bit + for bit in bits: + total |= resolve(bit) + bitfield &= ~total + return self + +func serialize(): + var serialized = {} + + var flags = FLAGS.keys() + var bits = FLAGS.values() + + var i = 0 + for flag in flags: + var bit = bits[i] + serialized[flag] = has(bit) + i += 1 + + return serialized + +func to_array(): + var ret = [] + + var flags = FLAGS.keys() + var bits = FLAGS.values() + + var i = 0 + for flag in flags: + var bit = bits[i] + if has(bit): + ret.append(flag) + i += 1 + + return ret + +func resolve(bit): + if typeof(default_bit) == TYPE_INT or typeof(default_bit) == TYPE_FLOAT: + default_bit = int(default_bit) + + if typeof(bit) == TYPE_INT or typeof(bit) == TYPE_FLOAT: + bit = int(bit) + + if typeof(default_bit) == typeof(bit): + if bit >= default_bit: + return bit + + if typeof(bit) == TYPE_OBJECT and bit.is_class(self.get_class()): + return bit.bitfield + + if (typeof(bit) == TYPE_ARRAY): + var ret = default_bit + + for b in bit: + ret = ret | resolve(b) + return ret + + if (Helpers.is_valid_str(bit)): + if (FLAGS.has(bit)): + return FLAGS[bit] + + if (not is_nan(float(bit))): + return int(bit) + + assert(false, 'Bitfield is invalid.') + + +func _init(bits = default_bit): + if bits == null: + bits = default_bit + bitfield = resolve(bits) + + +func _to_dict(): + if typeof(bitfield) == TYPE_INT: + return bitfield + else: + return str(bitfield) + + +func value_of(): + return bitfield + + +func _to_string(): + return str(bitfield) diff --git a/addons/discord_gd/classes/bit_field.gd.uid b/addons/discord_gd/classes/bit_field.gd.uid new file mode 100644 index 0000000..eaa59af --- /dev/null +++ b/addons/discord_gd/classes/bit_field.gd.uid @@ -0,0 +1 @@ +uid://csgxh04sf3r75 diff --git a/addons/discord_gd/classes/discord_interaction.gd b/addons/discord_gd/classes/discord_interaction.gd new file mode 100644 index 0000000..dcde269 --- /dev/null +++ b/addons/discord_gd/classes/discord_interaction.gd @@ -0,0 +1,343 @@ +class_name DiscordInteraction +""" +Represents a Discord interaction. +""" + +var bot +var replied = false +var deferred = false +var ephemeral = false + +# Compulsory +var id: String +var application_id: String +var type: String +var token: String + +# Optional +var message: Message +var channel_id: String +var guild_id: String +var member: Dictionary +var data: Dictionary + +var RESPONSE_TYPES = { + 'CHANNEL_MESSAGE_WITH_SOURCE': 4, + 'DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE': 5, + 'DEFERRED_UPDATE_MESSAGE': 6, + 'UPDATE_MESSAGE': 7, + 'APPLICATION_COMMAND_AUTOCOMPLETE_RESULT': 8 +} + +var TYPES = { + 1: 'PING', + 2: 'APPLICATION_COMMAND', + 3: 'MESSAGE_COMPONENT', + 4: 'APPLICATION_COMMAND_AUTOCOMPLETE' +} + + +func is_command() -> bool: + return type == 'APPLICATION_COMMAND' + + +func is_autocomplete() -> bool: + return type == 'APPLICATION_COMMAND_AUTOCOMPLETE' + + +func is_message_component() -> bool: + return type == 'MESSAGE_COMPONENT' + + +func is_button() -> bool: + return is_message_component() and data.component_type == 2 + + +func is_select_menu() -> bool: + return is_message_component() and data.component_type == 3 + + +func in_guild() -> bool: + return guild_id != '' and member != {} + + +func respond_autocomplete(choices: Array): + var payload = { + 'type': RESPONSE_TYPES['APPLICATION_COMMAND_AUTOCOMPLETE_RESULT'], + 'data': { + 'choices': choices + } + } + var res = await bot._send_request('/interactions/%s/%s/callback' % [id, token], payload) + return res + + +func fetch_reply(message_id: String = '@original'): + #assert(not ephemeral, 'Unable to fetch ephemeral Interaction reply.') + if ephemeral: + push_error('Unable to fetch ephemeral reply.') + return + + var msg = await bot._send_get('/webhooks/%s/%s/messages/%s' % [application_id, token, message_id]) + await bot._parse_message(msg) + + return Message.new(msg) + + +func reply(options: Dictionary): + if replied or deferred: + push_error('Already replied to Interaction.') + return + + options.type = RESPONSE_TYPES['CHANNEL_MESSAGE_WITH_SOURCE'] + var res = await _send_request('/interactions/%s/%s/callback' % [id, token], options) + replied = true + + return res + + +func edit_reply(options: Dictionary): + if (not replied) and (not deferred): + push_error('Unable to edit Interaction. Not replied.') + return + + var res = await _edit_message('@original', options) + replied = true + return res + + +func delete_reply(): + if ephemeral: + push_error('Unable to delete ephemeral Interaction reply.') + return + + return await _delete_message() + + +func defer_reply(options: Dictionary = {}): + if replied or deferred: + push_error('Already replied to Interaction.') + return + + options.type = RESPONSE_TYPES['DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE'] + var res = await _send_request('/interactions/%s/%s/callback' % [id, token], options) + deferred = true + return res + + +func update(options: Dictionary): + if replied or deferred: + push_error('Already replied to Interaction.') + return + + options.type = RESPONSE_TYPES['UPDATE_MESSAGE'] + var msg = await _send_request('/interactions/%s/%s/callback' % [id, token], options) + replied = true + return msg + + +func defer_update(options: Dictionary = {}): + if replied or deferred: + push_error('Already replied to Interaction.') + return + + options.type = RESPONSE_TYPES['DEFERRED_UPDATE_MESSAGE'] + var res = await _send_request('/interactions/%s/%s/callback' % [id, token], options) + deferred = true + return res + + +func follow_up(options: Dictionary): + options.type = RESPONSE_TYPES['CHANNEL_MESSAGE_WITH_SOURCE'] + var res = await _send_request( + '/webhooks/%s/%s' % [application_id, token], options, HTTPClient.METHOD_POST, true + ) + return res + + +func edit_follow_up(msg: Message, options: Dictionary): + var res = await _edit_message(msg.id, options) + return res + + +func delete_follow_up(msg: Message): + var res = await _delete_message(msg.id) + return res + + +func has(attribute): + return true if self[attribute] else false + + +func _delete_message(message_id: String = '@original'): + var res = await bot._send_get( + '/webhooks/%s/%s/messages/%s' % [application_id, token, message_id], + HTTPClient.METHOD_DELETE + ) + return res + + +func _edit_message(message_id: String, options: Dictionary): + options.type = RESPONSE_TYPES['CHANNEL_MESSAGE_WITH_SOURCE'] + var msg = await _send_request( + '/webhooks/%s/%s/messages/%s' % [application_id, token, message_id], + options, + HTTPClient.METHOD_PATCH + ) + return msg + + +func _send_request( + slug: String, options: Dictionary, method = HTTPClient.METHOD_POST, is_follow_up = false +): + var files = [] + if options.has('files'): + files = options.files + options.erase('files') + + var _type = options.type + options.erase('type') + + options.attachments = message.attachments if message != null else [] + + if options.has('ephemeral') and typeof(options.ephemeral) == TYPE_BOOL: + ephemeral = options.ephemeral + options.erase('ephemeral') + + var _fetch_reply = false + if options.has('fetch_reply'): + _fetch_reply = options.fetch_reply + options.erase('fetch_reply') + + var _embeds = [] + if options.has('embeds') and options.embeds.size() > 0: + for embed in options.embeds: + if typeof(embed) == TYPE_DICTIONARY: + _embeds.append(embed) + else: + _embeds.append(embed._to_dict()) + + var _components = [] + if options.has('components') and options.components.size() > 0: + for component in options.components: + if typeof(component) == TYPE_DICTIONARY: + _components.append(component) + else: + _components.append(component._to_dict()) + + var payload = { + 'type': _type, + 'data': + { + 'tts': options.tts if options.has('tts') else false, + 'content': options.content if options.has('content') else null, + 'embeds': _embeds, + 'allowed_mentions': options.allowed_mentions if options.has('allowed_mentions') else {}, + 'attachments': options.attachments if options.has('attachments') else [], + 'flags': MessageFlags.new('EPHEMERAL') if ephemeral else null, + 'components': _components + } + } + + if _type == RESPONSE_TYPES['UPDATE_MESSAGE']: + # Append the message parts from the original message if the options doesnt contain that part + if not options.has('tts'): + payload.data.tts = message.tts + if not options.has('content'): + payload.data.content = message.content + if not options.has('embeds'): + payload.data.embeds = message.embeds + if not options.has('components'): + payload.data.components = message.components + + if method == HTTPClient.METHOD_PATCH or is_follow_up: + payload = payload.data + + var res + var coroutine = await bot._send_raw_request(slug, {'payload': payload, 'files': files}, method) + + if is_follow_up: + coroutine = await bot._parse_message(res) + + return Message.new(res) + + if _fetch_reply: + return await fetch_reply('@original') + else: + return true + + +func _init(_bot, interaction: Dictionary): + bot = _bot + assert(Helpers.is_valid_str(interaction.id), 'Interaction must have an id') + assert( + Helpers.is_valid_str(interaction.application_id), 'Interaction must have an application id' + ) + assert(Helpers.is_valid_str(interaction.token), 'Interaction must have a token') + assert(interaction.has('type'), 'Interaction must have a type') + assert(Helpers.is_num(interaction.version), 'Interaction must have a version') + + id = interaction.id + application_id = interaction.application_id + token = interaction.token + type = TYPES[int(interaction.type)] + + if interaction.has('member'): + member = interaction.member + # Try to parse the member permissions + if member.has('permissions'): + member.permissions = Permissions.new(member.permissions) + + # Try to parse the member user + if member.has('user'): + member.user = User.new(bot, member.user) + + if interaction.has('guild_id'): + guild_id = interaction.guild_id + + if interaction.has('channel_id'): + channel_id = interaction.channel_id + + if interaction.has('data'): + data = interaction.data + if type == 'APPLICATION_COMMAND': + data.type = ApplicationCommand._COMMAND_TYPES[int(data.type)] + data = _parse_data_options(interaction.data) + + if interaction.has('message'): + await bot._parse_message(interaction.message) + + message = Message.new(interaction.message) + + +func _parse_data_options(data, option = false): + if option and data.has('type'): + data.type = ApplicationCommand._OPTION_TYPES[int(data.type)] + + if data.has('options'): + for i in range(len(data.options)): + data.options[i] = _parse_data_options(data.options[i], true) + return data + + +func _to_string(pretty: bool = false) -> String: + return JSON.stringify(_to_dict(), '\t') if pretty else JSON.stringify(_to_dict()) + + +func print(): + print(_to_string(true)) + + +func _to_dict() -> Dictionary: + return { + 'version': 1, + 'type': type, + 'token': token, + 'message': message._to_string() if message is Message else {}, + 'member': member, + 'id': id, + 'guild_id': guild_id, + 'data': data, + 'channel_id': channel_id, + 'application_id': application_id, + } diff --git a/addons/discord_gd/classes/discord_interaction.gd.uid b/addons/discord_gd/classes/discord_interaction.gd.uid new file mode 100644 index 0000000..3622e24 --- /dev/null +++ b/addons/discord_gd/classes/discord_interaction.gd.uid @@ -0,0 +1 @@ +uid://dklo5j3tsw3qa diff --git a/addons/discord_gd/classes/embed.gd b/addons/discord_gd/classes/embed.gd new file mode 100644 index 0000000..47fffef --- /dev/null +++ b/addons/discord_gd/classes/embed.gd @@ -0,0 +1,237 @@ +class_name Embed +""" +Stores data about a Discord Embed +and has functions to add, modify and edit +the various properties of an Embed. +""" + +var title: String: set = set_title, get = get_title +var type: String = 'rich': set = set_type, get = get_type +var description: String: set = set_description, get = get_description +var url: String: set = set_url, get = get_url +var timestamp: String: set = set_timestamp, get = get_timestamp +var color: set = set_color, get = get_color + +var footer = null +var image = null +var thumbnail = null +var video = null +var provider = null +var author = null +var fields: Array + + +func get_title(): + return title if Helpers.is_valid_str(title) else null + + +func get_type(): + return type if Helpers.is_valid_str(type) else null + + +func get_description(): + return description if Helpers.is_valid_str(description) else null + + +func get_url(): + return url if Helpers.is_valid_str(url) else null + + +func get_timestamp(): + return timestamp if Helpers.is_valid_str(timestamp) else null + + +func get_color(): + return color + + +func set_title(_title): + assert(Helpers.is_valid_str(_title), 'Invalid Type: title of Embed must be a String') + assert(_title.length() <= 256, 'title of Embed must be <= 256 characters') + title = _title + return self + + +func set_type(_type): + assert(Helpers.is_valid_str(_type), 'Invalid Type: type of Embed must be a String') + type = _type + return self + + +func set_description(_description): + assert( + Helpers.is_valid_str(_description), 'Invalid Type: description of Embed must be a String' + ) + assert(_description.length() <= 4096, 'Embed description must be <= 4096 characters') + description = _description + return self + + +func set_url(_url): + assert(Helpers.is_valid_str(_url), 'Invalid Type: url of Embed must be a String') + url = _url + return self + + +func set_timestamp(_timestamp = ''): + timestamp = Helpers.make_iso_string() + return self + + +func set_color(_color): + # RBG color + if typeof(_color) == TYPE_ARRAY: + color = (int(_color[0]) * 256 * 256) + (int(_color[1]) * 256) + int(_color[2]) + + # Hex color + elif typeof(_color) == TYPE_STRING and _color.begins_with('#'): + color = _color.replace('#', '0x').hex_to_int() + + # Decimal color + elif _color.is_valid_integer: + color = int(_color) + + return self + + +func set_footer(text: String, icon_url: String = '', proxy_icon_url: String = ''): + assert(Helpers.is_valid_str(text), 'Invalid Type: footer text of Embed must be a valid String') + assert(text.length() <= 2048, 'Embed footer text must be <= 2048 characters') + + footer = {'text': text, 'icon_url': icon_url, 'proxy_icon_url': proxy_icon_url} + return self + + +func set_image(url: String, width: int = -1, height: int = -1, proxy_url: String = ''): + assert(Helpers.is_valid_str(url), 'Invalid Type: image url of Embed must be a valid String') + image = { + 'url': url, + 'width': width if width != -1 else null, + 'height': height if height != -1 else null, + 'proxy_url': proxy_url if Helpers.is_valid_str(proxy_url) else null + } + return self + + +func set_thumbnail(url: String, width: int = -1, height: int = -1, proxy_url: String = ''): + assert(Helpers.is_valid_str(url), 'Embed thumbnail url must be a valid String') + thumbnail = { + 'url': url, + 'width': width if width != -1 else null, + 'height': height if height != -1 else null, + 'proxy_url': proxy_url if Helpers.is_valid_str(proxy_url) else null + } + return self + + +func set_video(url: String, width: int = -1, height: int = -1, proxy_url: String = ''): + assert(Helpers.is_valid_str(url), 'Invalid Type: video url of Embed must be a valid String') + video = { + 'url': url, + 'width': width if width != -1 else null, + 'height': height if height != -1 else null, + 'proxy_url': proxy_url if Helpers.is_valid_str(proxy_url) else null + } + return self + + +func set_provider(name: String, url: String = ''): + assert( + Helpers.is_valid_str(name), 'Invalid Type: provider name of Embed must be a valid String' + ) + provider = {'name': name, 'url': url if Helpers.is_valid_str(url) else null} + return self + + +func set_author( + name: String, url: String = '', icon_url: String = '', proxy_icon_url: String = '' +): + assert(Helpers.is_valid_str(name), 'Invalid Type: author name of Embed must be a valid String') + assert(name.length() <= 256, 'Embed author name must be <= 256 characters') + + author = { + 'name': name, + 'url': url if Helpers.is_valid_str(url) else null, + 'icon_url': icon_url if Helpers.is_valid_str(icon_url) else null, + 'proxy_icon_url': proxy_icon_url if Helpers.is_valid_str(proxy_icon_url) else null + } + return self + + +func add_field(name: String, value: String, inline: bool = false, index = -1): + assert(Helpers.is_valid_str(name), 'Invalid Type: field name of Embed must be a valid String') + assert(Helpers.is_valid_str(value), 'Invalid Type: field value of Embed must be a valid String') + + assert(name.length() <= 256, 'Embed field name must be <= 256 characters') + assert(value.length() <= 1024, 'Embed field value must be <= 1024 characters') + assert(fields.size() <= 25, 'Embed can have a max of 25 fields') + + var new_field = {'name': name, 'value': value, 'inline': inline} + if index == -1: + fields.append(new_field) + else: + fields.insert(index, new_field) + return self + + +func slice_fields(index: int, delete_count: int = 1, replace_fields: Array = []): + var n = fields.size() + assert(Helpers.is_num(index), 'Missing index must be provided to Embed.slice_fields') + assert(index > -1 and index < n, 'index out of bounds in Embed.slice_fields') + + var max_deletable = n - index + assert(delete_count <= max_deletable, 'delete_count out of bounds in Embed.slice_fields') + + while delete_count != 0: + fields.remove_at(index) + delete_count -= 1 + + if replace_fields.size() != 0: + # add fields + for field in replace_fields: + var inline = false + if field.size() == 3: + inline = field[2] + add_field(field[0], field[1], inline, index) + index += 1 + + return self + + +func _to_string(pretty: bool = false) -> String: + return JSON.stringify(_to_dict(), '\t') if pretty else JSON.stringify(_to_dict()) + +func print(): + print(_to_string(true)) + +func _to_dict() -> Dictionary: + var total = title + description + + if footer and footer.text: + total += footer.text + + if author and author.name: + total += author.name + + for field in fields: + total += field.name + total += field.value + + total = str(total).length() + assert(total <= 6000, 'Embed content must be <= 6000 characters in total') + + return { + 'title': title, + 'type': type, + 'description': description, + 'url': url, + 'timestamp': timestamp, + 'color': color, + 'footer': footer, + 'image': image, + 'thumbnail': thumbnail, + 'video': video, + 'provider': provider, + 'author': author, + 'fields': fields + } diff --git a/addons/discord_gd/classes/embed.gd.uid b/addons/discord_gd/classes/embed.gd.uid new file mode 100644 index 0000000..cf17385 --- /dev/null +++ b/addons/discord_gd/classes/embed.gd.uid @@ -0,0 +1 @@ +uid://cx0cqy20w5ygt diff --git a/addons/discord_gd/classes/helpers.gd b/addons/discord_gd/classes/helpers.gd new file mode 100644 index 0000000..587bb10 --- /dev/null +++ b/addons/discord_gd/classes/helpers.gd @@ -0,0 +1,75 @@ +class_name Helpers +""" +General purpose Helpers functions +used by discord.gd plugin +""" + +# Returns true if value if an int or real float +static func is_num(value) -> bool: + return typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT + + +# Returns true if value is a string +static func is_str(value) -> bool: + return typeof(value) == TYPE_STRING + + +# Returns true if the string has more than 1 character +static func is_valid_str(value) -> bool: + return is_str(value) and value.length() > 0 + + +# Return a ISO 8601 timestamp as a String +static func make_iso_string(datetime: Dictionary = Time.get_datetime_dict_from_system(true)) -> String: + var iso_string = '%s-%02d-%02dT%02d:%02d:%02d' % [datetime.year, datetime.month, datetime.day, datetime.hour, datetime.minute, datetime.second] + + return iso_string + + +# Pretty prints a Dictionary +static func print_dict(d: Dictionary) -> void: + print(JSON.stringify(d, '\t')) + + +# Saves a Dictionary to a file for debugging large dictionaries +static func save_dict(d: Dictionary, filename = 'saved_dict') -> void: + assert(typeof(d) == TYPE_DICTIONARY, 'type of d is not Dictionary in save_dict') + var file = FileAccess.open('user://%s%s.json' % [filename, str(Time.get_ticks_msec())], FileAccess.WRITE) + file.store_string(JSON.stringify(d, '\t')) + file.close() + print('Dictionary saved to file') + + +# Converts a raw image bytes to a png Image +static func to_png_image(bytes: PackedByteArray) -> Image: + var image = Image.new() + image.load_png_from_buffer(bytes) + return image + + +# Converts a Image to ImageTexture +static func to_image_texture(image: Image) -> ImageTexture: + var texture = ImageTexture.new() + texture.create_from_image(image) + return texture + + +# Ensures that the String's length is less than or equal to the specified length +static func assert_length(variable: String, length: int, msg: String): + assert(variable.length() <= length, msg) + + +# Convert the ISO string to a unix timestamp +static func iso2unix(iso_string: String) -> int: + var date := iso_string.split("T")[0].split("-") + var time := iso_string.split("T")[1].trim_suffix("Z").split(":") + + var datetime = { + year = date[0], + month = date[1], + day = date[2], + hour = time[0], + minute = time[1], + second = time[2], + } + return Time.get_unix_time_from_datetime_dict(datetime) diff --git a/addons/discord_gd/classes/helpers.gd.uid b/addons/discord_gd/classes/helpers.gd.uid new file mode 100644 index 0000000..be6b512 --- /dev/null +++ b/addons/discord_gd/classes/helpers.gd.uid @@ -0,0 +1 @@ +uid://q15tb8w4uum diff --git a/addons/discord_gd/classes/message.gd b/addons/discord_gd/classes/message.gd new file mode 100644 index 0000000..01beb69 --- /dev/null +++ b/addons/discord_gd/classes/message.gd @@ -0,0 +1,190 @@ +class_name Message +""" +Represents a Discord Message. +""" + +var id: String +var channel_id: String +var guild_id: String +var content: String +var timestamp: String +var edited_timestamp: String +var webhook_id: String +var type: String + +var author: User +var member: Dictionary +var activity: Dictionary +var message_reference: Dictionary +var referenced_message: Dictionary + +var tts: bool +var mention_everyone: bool +var pinned: bool + +var mentions: Array +var mention_roles: Array +var mention_channels: Array +var attachments: Array +var components: Array +var embeds: Array +var reactions: Array +var flags: MessageFlags + +var nonce + +var MESSAGE_TYPES = { + '0': 'DEFAULT', + '1': 'RECIPIENT_ADD', + '2': 'RECIPIENT_REMOVE', + '3': 'CALL', + '4': 'CHANNEL_NAME_CHANGE', + '5': 'CHANNEL_ICON_CHANGE', + '6': 'CHANNEL_PINNED_MESSAGE', + '7': 'GUILD_MEMBER_JOIN', + '8': 'USER_PREMIUM_GUILD_SUBSCRIPTION', + '9': 'USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_1', + '10': 'USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_2', + '11': 'USER_PREMIUM_GUILD_SUBSCRIPTION_TIER_3', + '12': 'CHANNEL_FOLLOW_ADD', + '14': 'GUILD_DISCOVERY_DISQUALIFIED', + '15': 'GUILD_DISCOVERY_REQUALIFIED', + '16': 'GUILD_DISCOVERY_GRACE_PERIOD_INITIAL_WARNING', + '17': 'GUILD_DISCOVERY_GRACE_PERIOD_FINAL_WARNING', + '18': 'THREAD_CREATED', + '19': 'REPLY', + '20': 'APPLICATION_COMMAND', + '21': 'THREAD_STARTER_MESSAGE', + '22': 'GUILD_INVITE_REMINDER' +} + +func _init(message: Dictionary): + # Compulsory + assert(typeof(message) == TYPE_DICTIONARY, 'Invalid type: message must be a Dictionary') + assert(message.id, 'Message must have an id') + id = message.id + assert(message.has('type'), 'Message must have a type') + + assert(message.has('channel_id') and message.channel_id and Helpers.is_valid_str(message.channel_id), 'Message must have a valid channel_id') + channel_id = message.channel_id + + if MESSAGE_TYPES.get(str(int(message.type))): + type = MESSAGE_TYPES.get(str(int(message.type))) + else: + assert(false, 'Message must have a valid type') + + assert(message.has('author'), 'Message must have an author') + + # Check if the message is sent by webhook + if message.has('webhook_id') and Helpers.is_str(message.webhook_id) and message.webhook_id.length() > 0: + # webhook sent a message + pass + else: + # sent by user + assert(message.author is User, 'author attribute of Mesage must be of type User') + author = message.author + + if message.has('flags'): + flags = MessageFlags.new(message.flags) + +# if message.channel.type != 'DM': +# assert(message.has('guild_id') and message.guild_id and Helpers.is_valid_str(message.guild_id), 'Message must have a valid guild_id') +# guild_id = message.guild_id + + if message.has('guild_id') and message.guild_id: + guild_id = message.guild_id + + #if not message.has('webhook_id'): + #assert(message.content.length() > 0 or message.embeds.size() > 0 or message.components.size() > 0 or message.attachments.size() > 0, 'Message must have a content or at least one of (embeds, components or attachments)') + content = message.content + + assert(message.timestamp, 'Message must have a timestamp') + timestamp = message.timestamp + + + + # Optional + if message.has('edited_timestamp') and message.edited_timestamp != null: + edited_timestamp = message.edited_timestamp + + if message.has('tts'): + tts = true if message.tts else false + + if message.has('mention_everyone'): + mention_everyone = true if message.mention_everyone else false + + if message.has('mentions') and typeof(message.mentions) == TYPE_ARRAY and message.mentions.size() > 0: + mentions = message.mentions + + if message.has('member') and message.member: + member = message.member + if message.has('mention_roles') and message.mention_roles: + mention_roles = message.mention_roles + if message.has('mention_channels') and message.mention_channels: + mention_channels = message.mention_channels + if message.has('attachments') and message.attachments: + attachments = message.attachments + if message.has('components') and message.components: + components = message.components + if message.has('embeds') and message.embeds: + embeds = message.embeds + if message.has('reactions') and message.reactions: + reactions = message.reactions + if message.has('pinned') and typeof(message.pinned) == TYPE_BOOL: + pinned = message.pinned + if message.has('message_reference') and message.message_reference: + message_reference = message.message_reference + if message.has('referenced_message') and message.referenced_message: + referenced_message = message.referenced_message +func _to_string(pretty: bool = false): + var data = { + 'id': id, + 'channel_id': channel_id, + 'guild_id': guild_id, + 'author': author, + 'member': member, + 'content': content, + 'timestamp': timestamp, + 'edited_timestamp': edited_timestamp, + 'tts': tts, + 'mention_everyone': mention_everyone, + 'mentions': mentions, + 'mention_roles': mention_roles, + 'mention_channels': mention_channels, + 'attachments': attachments, + 'components': components, + 'embeds': embeds, + 'reactions': reactions, + 'pinned': pinned, + 'type': type, + 'message_reference': message_reference, + 'referenced_message': referenced_message, + 'flags': flags.bitfield + } + + return JSON.stringify(data, '\t') if pretty else JSON.stringify(data) + +func print(): + print(_to_string(true)) + +func has(attribute): + return true if self[attribute] else false + +func slice_attachments(index: int, delete_count: int = 1, replace_attachments: Array = []): + var n = attachments.size() + assert(Helpers.is_num(index), 'index must be provided to Message.slice_attachments') + assert(index > -1 and index < n, 'index out of bounds in Message.slice_attachments') + + var max_deletable = n - index + assert(delete_count <= max_deletable, 'delete_count out of bounds in Message.attachments') + + while delete_count != 0: + attachments.remove_at(index) + delete_count -= 1 + + if replace_attachments.size() > 0: + for attachment in replace_attachments: + assert(attachment.has('id') and Helpers.is_valid_str(attachment.id), 'Missing id for attachment in replace_attachments in slice_attachments') + attachments.append_array(replace_attachments) + + return self diff --git a/addons/discord_gd/classes/message.gd.uid b/addons/discord_gd/classes/message.gd.uid new file mode 100644 index 0000000..b41ac8b --- /dev/null +++ b/addons/discord_gd/classes/message.gd.uid @@ -0,0 +1 @@ +uid://dtoffh2nlc7di diff --git a/addons/discord_gd/classes/message_action_row.gd b/addons/discord_gd/classes/message_action_row.gd new file mode 100644 index 0000000..9a39ae0 --- /dev/null +++ b/addons/discord_gd/classes/message_action_row.gd @@ -0,0 +1,68 @@ +class_name MessageActionRow +""" +Represnts a Discord message action row which has components +""" + +var components: Array + + +func add_component(component, index = -1): + assert(components.size() + 1 <= 5, 'MessageActionRow cannot have more than 5 components.') + assert(component.type != 1, 'MessageActionRow cannot contain another MessageActionRow.') + + var same_custom_id = false + for _component in components: + if Helpers.is_valid_str(_component.get_custom_id()): + if _component.get_custom_id() == component.get_custom_id(): + same_custom_id = true + break + assert(same_custom_id == false, 'MessageActionRow must contain components with unique custom_id') + + if index == -1: + components.append(component) + else: + components.insert(index, component) + return self + + +func slice_components(index: int, delete_count: int = 1, replace_components: Array = []): + var n = components.size() + assert(Helpers.is_num(index), 'index must be provided to MessageActionRow.slice_components') + assert(index > -1 and index < n, 'index out of bounds in MessageActionRow.slice_components') + + var max_deletable = n - index + assert(delete_count <= max_deletable, 'delete_count out of bounds in MessageActionRow.slice_components') + + while delete_count != 0: + components.remove_at(index) + delete_count -= 1 + + if replace_components.size() != 0: + # add components + for component in replace_components: + add_component(component, index) + index += 1 + + return self + + + +func _to_string(pretty: bool = false) -> String: + return JSON.stringify(_to_dict(), '\t') if pretty else JSON.stringify(_to_dict()) + + +func print(): + print(_to_string(true)) + + +func _to_dict() -> Dictionary: + assert(components.size() <= 5, 'MessageActionRow cannot have more than 5 components.') + + var _components = [] + for component in components: + _components.append(component._to_dict()) + + return { + 'type': 1, + 'components': _components + } diff --git a/addons/discord_gd/classes/message_action_row.gd.uid b/addons/discord_gd/classes/message_action_row.gd.uid new file mode 100644 index 0000000..e925c5a --- /dev/null +++ b/addons/discord_gd/classes/message_action_row.gd.uid @@ -0,0 +1 @@ +uid://dr83pxgdkl46x diff --git a/addons/discord_gd/classes/message_button.gd b/addons/discord_gd/classes/message_button.gd new file mode 100644 index 0000000..386f04a --- /dev/null +++ b/addons/discord_gd/classes/message_button.gd @@ -0,0 +1,101 @@ +class_name MessageButton +""" +Represents a Discord message button. +""" + +var _STYLES = {0: 'DEFAULT', 1: 'PRIMARY', 2: 'SECONDARY', 3: 'SUCCESS', 4: 'DANGER', 5: 'LINK'} + +enum STYLES { DEFAULT, PRIMARY, SECONDARY, SUCCESS, DANGER, LINK } + +var label: String: set = set_label, get = get_label +var custom_id: String: set = set_custom_id, get = get_custom_id +var url: String: set = set_url, get = get_url +var disabled: bool = false: set = set_disabled, get = get_disabled +var emoji: Dictionary + +var _style: set = set_style +var type: int = 2 + + +func set_style(style_number: int): + _style = style_number + return self + + +func get_style_string(): + return _STYLES[_style] + + +func set_label(new_label: String): + assert(new_label.length() <= 80, 'label of MessageButton must be max 80 characters.') + label = new_label + return self + + +func get_label() -> String: + return label + + +func set_custom_id(new_custom_id): + assert(new_custom_id.length() <= 80, 'custom_id of MessageButton must be max 100 characters.') + custom_id = new_custom_id + return self + + +func get_custom_id() -> String: + return custom_id + + +func set_url(new_url): + url = new_url + return self + + +func get_url() -> String: + return url + + +func set_disabled(new_value: bool): + disabled = new_value + return self + + +func get_disabled() -> bool: + return disabled + + +func set_emoji(new_emoji: Dictionary): + emoji = new_emoji + return self + +func get_emoji() -> Dictionary: + return emoji + + +func _to_string(pretty: bool = false) -> String: + return JSON.stringify(_to_dict(), '\t') if pretty else JSON.stringify(_to_dict()) + + +func print(): + print(_to_string(true)) + + +func _to_dict() -> Dictionary: + # Default style is primary + if _style == 0: + _style = 1 + + if _style == STYLES.LINK: + # Must have a url + assert(Helpers.is_valid_str(url), 'A LINK MessageButton must have a url.') + return {'type': type, 'style': _style, 'label': label, 'url': url, 'disabled': disabled} + else: + assert(Helpers.is_valid_str(custom_id), 'A button must have a custom_id.') + return { + 'type': type, + 'style': _style, + 'label': label, + 'custom_id': custom_id, + 'disabled': disabled, + 'emoji': emoji if emoji else null + } diff --git a/addons/discord_gd/classes/message_button.gd.uid b/addons/discord_gd/classes/message_button.gd.uid new file mode 100644 index 0000000..2f03e02 --- /dev/null +++ b/addons/discord_gd/classes/message_button.gd.uid @@ -0,0 +1 @@ +uid://c2mdufrdsqisx diff --git a/addons/discord_gd/classes/message_flags.gd b/addons/discord_gd/classes/message_flags.gd new file mode 100644 index 0000000..28fca44 --- /dev/null +++ b/addons/discord_gd/classes/message_flags.gd @@ -0,0 +1,28 @@ +class_name MessageFlags extends BitField +""" +Represents a bitfield of Discord message flags. +""" + +func _init(bits = default_bit): + default_bit = 0 + + if bits == null: + bits = default_bit + + FLAGS = { + 'CROSSPOSTED': 1 << 0, + 'IS_CROSSPOST': 1 << 1, + 'SUPPRESS_EMBEDS': 1 << 2, + 'SOURCE_MESSAGE_DELETED': 1 << 3, + 'URGENT': 1 << 4, + 'HAS_THREAD': 1 << 5, + 'EPHEMERAL': 1 << 6, + 'LOADING': 1 << 7, + } + + bitfield = resolve(bits) + + +func missing(bits): + var BF = load('res://addons/discord_gd/classes/message_flags.gd') + return BF.new(bits).remove(self).to_array() diff --git a/addons/discord_gd/classes/message_flags.gd.uid b/addons/discord_gd/classes/message_flags.gd.uid new file mode 100644 index 0000000..a4aa948 --- /dev/null +++ b/addons/discord_gd/classes/message_flags.gd.uid @@ -0,0 +1 @@ +uid://dbkf1n3hfcdj8 diff --git a/addons/discord_gd/classes/permissions.gd b/addons/discord_gd/classes/permissions.gd new file mode 100644 index 0000000..386aae3 --- /dev/null +++ b/addons/discord_gd/classes/permissions.gd @@ -0,0 +1,67 @@ +class_name Permissions extends BitField +""" +Represents a bitfield of Discord permissions. +""" + +var ALL +const DEFAULT = 104324673 + + +func _init(bits = default_bit): + default_bit = 0 + + if bits == null: + bits = default_bit + + FLAGS = { + 'CREATE_INSTANT_INVITE': 1 << 0, + 'KICK_MEMBERS': 1 << 1, + 'BAN_MEMBERS': 1 << 2, + 'ADMINISTRATOR': 1 << 3, + 'MANAGE_CHANNELS': 1 << 4, + 'MANAGE_GUILD': 1 << 5, + 'ADD_REACTIONS': 1 << 6, + 'VIEW_AUDIT_LOG': 1 << 7, + 'PRIORITY_SPEAKER': 1 << 8, + 'STREAM': 1 << 9, + 'VIEW_CHANNEL': 1 << 10, + 'SEND_MESSAGES': 1 << 11, + 'SEND_TTS_MESSAGES': 1 << 12, + 'MANAGE_MESSAGES': 1 << 13, + 'EMBED_LINKS': 1 << 14, + 'ATTACH_FILES': 1 << 15, + 'READ_MESSAGE_HISTORY': 1 << 16, + 'MENTION_EVERYONE': 1 << 17, + 'USE_EXTERNAL_EMOJIS': 1 << 18, + 'VIEW_GUILD_INSIGHTS': 1 << 19, + 'CONNECT': 1 << 20, + 'SPEAK': 1 << 21, + 'MUTE_MEMBERS': 1 << 22, + 'DEAFEN_MEMBERS': 1 << 23, + 'MOVE_MEMBERS': 1 << 24, + 'USE_VAD': 1 << 25, + 'CHANGE_NICKNAME': 1 << 26, + 'MANAGE_NICKNAMES': 1 << 27, + 'MANAGE_ROLES': 1 << 28, + 'MANAGE_WEBHOOKS': 1 << 29, + 'MANAGE_EMOJIS_AND_STICKERS': 1 << 30, + 'USE_APPLICATION_COMMANDS': 1 << 31, + 'REQUEST_TO_SPEAK': 1 << 32, + 'MANAGE_THREADS': 1 << 34, + 'USE_PUBLIC_THREADS': 1 << 35, + 'USE_PRIVATE_THREADS': 1 << 36, + 'USE_EXTERNAL_STICKERS': 1 << 37, + } + + bitfield = resolve(bits) + + var values = FLAGS.values() + var prev = default_bit + for value in values: + prev |= value + ALL = prev + + +func missing(bits): + var BF = load('res://addons/discord_gd/classes/permissions.gd') + return BF.new(bits).remove(self).to_array() diff --git a/addons/discord_gd/classes/permissions.gd.uid b/addons/discord_gd/classes/permissions.gd.uid new file mode 100644 index 0000000..b72f199 --- /dev/null +++ b/addons/discord_gd/classes/permissions.gd.uid @@ -0,0 +1 @@ +uid://pov5klkwcdw3 diff --git a/addons/discord_gd/classes/select_menu.gd b/addons/discord_gd/classes/select_menu.gd new file mode 100644 index 0000000..6ac6e3a --- /dev/null +++ b/addons/discord_gd/classes/select_menu.gd @@ -0,0 +1,124 @@ +class_name SelectMenu +""" +Represents a Discord select menu. +""" + +var custom_id: String: set = set_custom_id, get = get_custom_id +var placeholder: String: set = set_placeholder, get = get_placeholder +var options: Array: set = set_options, get = get_options + +var min_values = 1: set = set_min_values, get = get_min_values +var max_values = 1: set = set_max_values, get = get_max_values + +var disabled: bool = false: set = set_disabled, get = get_disabled + +var type: int = 3 + + +func set_custom_id(new_custom_id): + Helpers.assert_length(new_custom_id, 100, 'custom_id of SelectMenu cannot be more than 100 characters.') + custom_id = new_custom_id + return self + + +func get_custom_id() -> String: + return custom_id + + +func add_option(value: String, label: String, data: Dictionary = {}): + assert(options.size() <= 25, 'options of SelectMenu cannot have more than 25 options') + assert(Helpers.is_valid_str(value), 'value of SelectMenu option must be a valid String') + Helpers.assert_length(value, 100, 'value of SelectMenu option cannot be more than 100 characters') + assert(Helpers.is_valid_str(label), 'SelectMenu option must have a label') + Helpers.assert_length(label, 100, 'label of SelectMenu option cannot be more than 100 characters') + + # Parse data + #{description: "", emoji: {}, default = false} + var _data = { + 'value': value, + 'label': label + } + if data.has('description'): + assert(typeof(data.description) == TYPE_STRING, 'description of SelectMenu option must be a String') + Helpers.assert_length(data.description, 100, 'description of SelectMenu cannot be more than 100 characters') + _data['description'] = data.description + + if data.has('emoji'): + assert(typeof(data.emoji) == TYPE_DICTIONARY, 'emoji of SelectMenu option must be a Dictionary') + _data['emoji'] = data.emoji + + if data.has('default'): + assert(typeof(data.default) == TYPE_BOOL, 'default of SelectMenu option must be a bool') + _data['default'] = data.default + + options.append(_data) + + return self + + +func set_options(new_options: Array): + options = new_options + return self + + +func get_options() -> Array: + return options + + +func set_placeholder(new_placeholder: String): + Helpers.assert_length(new_placeholder, 100, 'placeholder of SelectMenu cannot be more than 100 characters.') + placeholder = new_placeholder + return self + + +func get_placeholder() -> String: + return placeholder + + +func set_min_values(new_min_values: int): + assert(new_min_values <= 25, 'min_values of SelectMenu cannot be more than 25') + min_values = new_min_values + return self + + +func get_min_values() -> int: + return min_values + + +func set_max_values(new_max_values: int): + assert(new_max_values <= 25, 'max_values of SelectMenu cannot be more than 25') + max_values = new_max_values + return self + +func get_max_values() -> int: + return max_values + + +func set_disabled(new_value: bool): + disabled = new_value + return self + +func get_disabled() -> bool: + return disabled + + +func _to_string(pretty: bool = false) -> String: + return JSON.stringify(_to_dict(), '\t') if pretty else JSON.stringify(_to_dict()) + + +func print(): + print(_to_string(true)) + + +func _to_dict() -> Dictionary: + # Default style is primary + assert(Helpers.is_valid_str(custom_id), 'A button must have a custom_id.') + return { + 'type': type, + 'custom_id': custom_id, + 'options': options, + 'placeholder': placeholder, + 'min_values': min_values, + 'max_values': max_values, + 'disabled': disabled, + } diff --git a/addons/discord_gd/classes/select_menu.gd.uid b/addons/discord_gd/classes/select_menu.gd.uid new file mode 100644 index 0000000..3f4dbba --- /dev/null +++ b/addons/discord_gd/classes/select_menu.gd.uid @@ -0,0 +1 @@ +uid://v5s48xly81hk diff --git a/addons/discord_gd/classes/user.gd b/addons/discord_gd/classes/user.gd new file mode 100644 index 0000000..9e65cbc --- /dev/null +++ b/addons/discord_gd/classes/user.gd @@ -0,0 +1,156 @@ +class_name User +""" +Represents a Discord User. +""" + +var id: String +var username: String +var discriminator: String +var avatar: String + +# Optional +var bot: bool +var system: bool +var mfa_enabled: bool +var locale: String +var verified: bool +var email: String +var flags: int +var premium_type: int +var public_flags: int + +var client + +const AVATAR_URL_FORMATS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] +const AVATAR_URL_SIZES = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] + + +func get_display_avatar_url(options: Dictionary = {}) -> String: + """ + options { + format: String, one of webp, png, jpg, jpeg, gif (default png), + size: int, one of 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 (default 256), + dynamic: bool, if true the format will automatically change to gif for animated avatars (default false) + } + """ + + if options.has('format'): + assert(options.format in AVATAR_URL_FORMATS, 'Invalid avatar_url provided to get_display_avatar') + else: + options.format = 'png' + + if options.has('size'): + assert(int(options.size) in AVATAR_URL_SIZES, 'Invalid size provided to get_display_avatar') + else: + options.size = 256 + + if options.has('dynamic'): + assert(typeof(options.dynamic) == TYPE_BOOL, 'dynamic attribute must be of type bool in get_display_avatar') + if Helpers.is_valid_str(avatar) and avatar.begins_with('a_'): + options.format = 'gif' + else: + options.dynamic = false + + if not Helpers.is_valid_str(avatar): + return get_default_avatar_url() + + return client._cdn_base + '/avatars/%s/%s.%s?size=%s' % [id, avatar, options.format, options.size] + + +func get_default_avatar_url() -> String: + var moduloed_discriminator = int(discriminator) % 5 + return client._cdn_base + '/embed/avatars/%s.png' % moduloed_discriminator + + +func get_display_avatar(options: Dictionary = {}) -> PackedByteArray: + var png_bytes = await client._send_get_cdn(get_display_avatar_url(options)) + return png_bytes + + +func get_default_avatar() -> PackedByteArray: + var png_bytes = await client._send_get_cdn(get_default_avatar_url()) + return png_bytes + + +func _init(_client, user): + client = _client + # Compulsory + assert(user.has('id'), 'User must have an id') + assert(user.has('username'), 'User must have a username') + assert(user.has('discriminator'), 'User must have a discriminator') + + + id = user.id + username = user.username + discriminator = user.discriminator + if user.avatar: + avatar = user.avatar + + # Optional + + if user.has('bot') and user.bot != null: + assert(typeof(user.bot) == TYPE_BOOL, 'bot attribute of User must be bool') + bot = user.bot + else: + bot = false + + if user.has('system') and user.system != null: + assert(typeof(user.system) == TYPE_BOOL, 'system attribute of User must be bool') + system = user.system + else: + system = false + + if user.has('mfa_enabled') and user.mfa_enabled != null: + assert(typeof(user.mfa_enabled) == TYPE_BOOL, 'mfa_enabled attribute of User must be bool') + mfa_enabled = user.mfa_enabled + else: + mfa_enabled = false + + if user.has('verified') and user.verified != null: + assert(typeof(user.verified) == TYPE_BOOL, 'verified attribute of User must be bool') + verified = user.verified + else: + verified = false + + if user.has('locale') and user.locale != null: + assert(typeof(user.locale) == TYPE_STRING, 'locale attribute of User must be String') + locale = user.locale + + if user.has('email') and user.email != null: + assert(typeof(user.email) == TYPE_STRING, 'email attribute of User must be String') + email = user.email + + if user.has('flags') and user.flags != null: + assert(Helpers.is_num(user.flags), 'flags attribute of User must be int') + flags = user.flags + + if user.has('premium_type') and user.premium_type != null: + assert(Helpers.is_num(user.premium_type), 'premium_type attribute of User must be int') + premium_type = user.premium_type + + if user.has('public_flags') and user.public_flags != null: + assert(Helpers.is_num(user.public_flags), 'public_flags attribute of User must be int') + public_flags = user.public_flags + + +func _to_string(pretty: bool = false): + var data = { + 'id': id, + 'username': username, + 'discriminator': discriminator, + 'avatar': avatar, + 'bot': bot, + 'system': system, + 'mfa_enabled': mfa_enabled, + 'locale': locale, + 'verified': verified, + 'email': email, + 'flags': flags, + 'premium_type': premium_type, + 'public_flags': public_flags + } + return JSON.stringify(data, '\t') if pretty else JSON.stringify(data) + + +func print(): + print(_to_string(true)) diff --git a/addons/discord_gd/classes/user.gd.uid b/addons/discord_gd/classes/user.gd.uid new file mode 100644 index 0000000..d987ae3 --- /dev/null +++ b/addons/discord_gd/classes/user.gd.uid @@ -0,0 +1 @@ +uid://1uaxjy7fw8f2 diff --git a/addons/discord_gd/discord.gd b/addons/discord_gd/discord.gd new file mode 100644 index 0000000..cafa572 --- /dev/null +++ b/addons/discord_gd/discord.gd @@ -0,0 +1,1308 @@ +class_name DiscordBot +extends Node + +""" +Main script for discord.gd plugin +Copyright 2021-present, Delano Lourenco +For Copyright and License: See LICENSE.md +""" + + +#region Public Variables + +var TOKEN: String +var VERBOSE: bool = false +var INTENTS: int = 513 + +# Caches +var user: User +var application: Dictionary +var guilds = {} +var channels = {} +var users = {} + +#endregion + + + +#region Signals + +signal bot_ready(bot) # bot: DiscordBot +signal guild_create(bot, guild) # bot: DiscordBot, guild: Dictionary +signal guild_update(bot, guild) # bot: DiscordBot, guild: Dictionary +signal guild_delete(bot, guild) # bot: DiscordBot, guild: Dictionary +signal message_create(bot, message, channel) # bot: DiscordBot, message: Message, channel: Dictionary +signal message_delete(bot, message) # bot: DiscordBot, message: Dictionary +signal interaction_create(bot, interaction) # bot: DiscordBot, interaction: DiscordInteraction +signal message_reaction_add(bot, data) # bot: DiscordBot, data: Dictionary +signal message_reaction_remove(bot, data) # bot: DiscordBot, data: Dictionary +signal message_reaction_remove_all(bot, data) # bot: DiscordBot, data: Dictionary +signal message_reaction_remove_emoji(bot, data) # bot: DiscordBot, data: Dictionary + +#endregion + + + + +#region Private Variables +var _gateway_base = 'wss://gateway.discord.gg/?v=9&encoding=json' +var _https_domain = 'https://discord.com' +var _api_slug = '/api/v9' +var _https_base = _https_domain + _api_slug +var _cdn_base = 'https://cdn.discordapp.com' +var _headers: Array +var _client: WebSocketPeer +var _sess_id: String +var _last_seq: float +var _invalid_session_is_resumable: bool +var _heartbeat_interval: int +var _heartbeat_ack_received = true + +#endregion + + + +# Count of the number of guilds initially loaded +var guilds_loaded = 0 + +const CHANNEL_TYPES = { + '0': 'GUILD_TEXT', + '1': 'DM', + '2': 'GUILD_VOICE', + '3': 'GROUP_DM', + '4': 'GUILD_CATEGORY', + '5': 'GUILD_NEWS', + '10': 'GUILD_NEWS_THREAD', + '11': 'GUILD_PUBLIC_THREAD', + '12': 'GUILD_PRIVATE_THREAD', + '13': 'GUILD_STAGE_VOICE', + '14': 'GUILD_DIRECTORY', + '15': 'GUILD_FORUM', + '16': 'GUILD_MEDIA', +} + +var GUILD_ICON_SIZES = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096] + +var ACTIVITY_TYPES = {'GAME': 0, 'STREAMING': 1, 'LISTENING': 2, 'WATCHING': 3, 'COMPETING': 5} + +var PRESENCE_STATUS_TYPES = ['ONLINE', 'DND', 'IDLE', 'INVISIBLE', 'OFFLINE'] + +# Public Functions +func login() -> void: + if VERBOSE: + print("Logging in...") + assert(TOKEN.length() > 10, 'ERROR: Unable to login. TOKEN attribute not set.') + _headers = [ + 'Authorization: Bot %s' % TOKEN, + 'User-Agent: discord.gd (https://github.com/3ddelano/discord.gd)' + ] + + var err = _client.connect_to_url(_gateway_base) + + if err == ERR_INVALID_PARAMETER: + if VERBOSE: + print('Login Error: Invalid ws url') + return + + if err == ERR_ALREADY_IN_USE: + if VERBOSE: + print('Login Error: Already logged in or logging in') + return + + if err != OK: + if VERBOSE: + print('Login Error: %s (%s)' % [error_string(err), err]) + return + + + +#region messages + +func send(messageorchannelid, content, options: Dictionary = {}) -> Message: + # channel + var res = await _send_message_request(messageorchannelid, content, options) + return res + + +func reply(message: Message, content, options: Dictionary = {}) -> Message: + options.message_reference = {'message_id': message.id} + var res = await _send_message_request(message, content, options) + return res + + +func edit(message: Message, content, options: Dictionary = {}) -> Message: + var res = await _send_message_request(message, content, options, HTTPClient.METHOD_PATCH) + return res + + +func delete(message: Message): + var res = await _send_message_request(message, '', {}, HTTPClient.METHOD_DELETE) + return res + +#endregion + + + +#region threads + +func start_thread(message: Message, thread_name: String, duration: int = 60 * 24) -> Dictionary: + var payload = {'name': thread_name, 'auto_archive_duration': duration} + var res = await _send_request( + '/channels/%s/messages/%s/threads' % [message.channel_id, message.id], payload + ) + + return res + +#endregion + + + +#region channels + +# See https://discord.com/developers/docs/resources/guild#create-guild-channel +# All parameters are optional and nullable excluding data.name +func create_channel(guild_id: String, data: Dictionary) -> Dictionary: + var res = await _send_request('/guilds/%s/channels' % guild_id, data) + return res + + +# See https://discord.com/developers/docs/resources/channel#get-channel +func get_channel(channel_id: String) -> Dictionary: + var res = await _send_get('/channels/%s' % channel_id) + return res + + +# See https://discord.com/developers/docs/resources/channel#modify-channel +func update_channel(channel_id: String, data: Dictionary) -> Dictionary: + var res = await _send_request('/channels/%s' % channel_id, data, HTTPClient.METHOD_PATCH) + return res + + +# See https://discord.com/developers/docs/resources/channel#deleteclose-channel +func delete_channel(channel_id: String): + var res = await _send_request('/channels/%s' % channel_id, {}, HTTPClient.METHOD_DELETE) + return res + + +# See https://discord.com/developers/docs/resources/message#get-channel-messages +# The before, after, and around parameters are mutually exclusive, only one may be passed at a time. +func get_channel_messages(channel_id: String, limit := 50, filter_type := "", filter_value := ""): + if filter_type != "": + assert(filter_type in ["before", "after", "around"], "Invalid filter type: %s, Expected: before, after, around" % filter_type) + assert(filter_value != "", "Filter value must not be empty") + return await _send_get('/channels/%s/messages?limit=%s&%s=%s' % [channel_id, limit, filter_type, filter_value]) + return await _send_get('/channels/%s/messages?limit=%s' % [channel_id, limit]) + + +# See https://discord.com/developers/docs/resources/message#get-channel-message +func get_channel_message(channel_id: String, message_id: String) -> Message: + var res = await _send_get('/channels/%s/messages/%s' % [channel_id, message_id]) + await _parse_message(res) + return Message.new(res) + + +func create_dm_channel(user_id: String) -> Dictionary: + var res = await _send_request('/users/@me/channels', {'recipient_id': user_id}) + if typeof(res) == TYPE_DICTIONARY: + _clean_channel(res) + return res + + +func permissions_in(channel_id: String): + # Permissions for the bot in a channel + return permissions_for(user.id, channel_id) + +#endregion + + + +#region guilds + +func get_guild_icon(guild_id: String, size: int = 256) -> PackedByteArray: + assert(Helpers.is_valid_str(guild_id), 'Invalid Type: guild_id must be a valid String') + + var guild = guilds.get(str(guild_id)) + + if not guild: + push_error('Guild not found.') + return PackedByteArray() + + if not guild.icon: + push_error('Guild has no icon set.') + return PackedByteArray() + + if size != 256: + assert(size in GUILD_ICON_SIZES, 'Invalid size for guild icon provided') + + var png_bytes = await _send_get_cdn('/icons/%s/%s.png?size=%s' % [guild.id, guild.icon, size]) + return png_bytes + + +func get_guild_emojis(guild_id: String) -> Array: + var res = await _send_get('/guilds/%s/emojis' % guild_id) + return res + + +func get_guild_member(guild_id: String, member_id: String) -> Dictionary: + var member = await _send_get('/guilds/%s/members/%s' % [guild_id, member_id]) + return member + +#endregion + + + +#region guild member + +func remove_member_role(guild_id: String, user_id: String, role_id: String): + var res = await _send_get('/guilds/%s/members/%s/roles/%s' % [guild_id, user_id, role_id], HTTPClient.METHOD_DELETE) + return res + + +func add_member_role(guild_id: String, user_id: String, role_id: String): + var res = await _send_request('/guilds/%s/members/%s/roles/%s' % [guild_id, user_id, role_id], {}, HTTPClient.METHOD_PUT) + return res + + +func ban_member(guild_id: String, user_id: String, opts = {delete_message_seconds = 0}): + var res = await _send_request('/guilds/%s/bans/%s' % [guild_id, user_id], opts, HTTPClient.METHOD_PUT) + return res + + +func unban_member(guild_id: String, user_id: String): + var res = await _send_request('/guilds/%s/bans/%s' % [guild_id, user_id], {}, HTTPClient.METHOD_DELETE) + return res + + +func permissions_for(user_id: String, channel_id: String): + # Permissions for a user in a channel + if not channels.has(channel_id): + push_error('Channel with the id' + channel_id + ' not found.') + return Permissions.new(Permissions.new().ALL) + + var channel = channels[channel_id] + var guild = guilds[channel.guild_id] + + # Check for guild owner + if user_id == guild.owner_id: + return Permissions.new(Permissions.new().ALL) + + # @everyone base role + var permissions = Permissions.new(guild.roles[guild.id].permissions) + if not guild.members.has(user_id): + push_warning('Member not found in cached members. Make sure the GUILD_MEMBERS intent is setup.') + return permissions + + var role_ids = guild.members[user_id].roles + + # Apply member global roles + for role_id in role_ids: + permissions.add(guild.roles[role_id].permissions) + + if permissions.has('ADMINISTRATOR'): + return Permissions.new(Permissions.new().ALL) + + var overwrites = channel.permission_overwrites + + # Apply @everyone overwrite + for overwrite in overwrites: + if overwrite.id == guild.id: + permissions.remove(overwrite.deny) + permissions.add(overwrite.allow) + break + + # Apply member roles overwrite + for overwrite in overwrites: + if overwrite.id in role_ids: + permissions.remove(overwrite.deny) + for overwrite in overwrites: + if overwrite.id in role_ids: + permissions.add(overwrite.allow) + + # Apply user overwrite + for overwrite in overwrites: + if overwrite.id == user_id: + permissions.remove(overwrite.deny) + permissions.add(overwrite.allow) + break + + return permissions + +#endregion + + + +#region roles + +func create_role(guild_id: String, p_opts: Dictionary): + var opts = { + name = "new role", + permissions = Permissions.DEFAULT, # Permissions object + color = 0, + hoist = false, + icon = null, # String + unicode_emoji = null, # String + mentionable = false + } + + for key in p_opts: + opts[key] = p_opts[key] + + if opts.permissions is Permissions: + opts.permissions = opts.permissions.value_of() + var res = await _send_request('/guilds/%s/roles' % [guild_id], opts, HTTPClient.METHOD_POST) + return res + + +func update_role(guild_id: String, role_id: String, opts: Dictionary): + if opts.has("permissions") and opts.permissions is Permissions: + opts.permissions = opts.permissions.value_of() + + var res = await _send_request('/guilds/%s/roles/%s' % [guild_id, role_id], opts, HTTPClient.METHOD_PATCH) + return res + + +func delete_role(guild_id: String, role_id: String): + var res = await _send_request('/guilds/%s/roles/%s' % [guild_id, role_id], {}, HTTPClient.METHOD_DELETE) + return res + +#endregion + + + +#region reactions + +# ONLY custom emojis will work, pass in only the Id of the emoji to the custom_emoji +func create_reaction(messageordict, custom_emoji: String) -> int: + assert(Helpers.is_valid_str(custom_emoji), 'Invalid Type: custom_emoji must be a String') + custom_emoji = 'a:' + custom_emoji + assert(messageordict is Message or typeof(messageordict) == TYPE_DICTIONARY, 'Invalid type: Expected a Message or Dictionary') + + if typeof(messageordict) == TYPE_DICTIONARY and messageordict.has('message_id'): + messageordict.id = messageordict.message_id + + var status_code = await _send_get('/channels/%s/messages/%s/reactions/%s/@me' % [messageordict.channel_id, messageordict.id, custom_emoji], HTTPClient.METHOD_PUT, ['Content-Length:0']) + return status_code + + +func delete_reaction(messageordict, custom_emoji: String, userid: String = '@me') -> int: + assert(Helpers.is_valid_str(custom_emoji), 'Invalid Type: custom_emoji must be a String') + custom_emoji = 'a:' + custom_emoji + assert(messageordict is Message or typeof(messageordict) == TYPE_DICTIONARY, 'Invalid type: Expected a Message or Dictionary') + + if typeof(messageordict) == TYPE_DICTIONARY and messageordict.has('message_id'): + messageordict.id = messageordict.message_id + + var status_code = await _send_get('/channels/%s/messages/%s/reactions/%s/%s' % [messageordict.channel_id, messageordict.id, custom_emoji, userid], HTTPClient.METHOD_DELETE, ['Content-Length:0']) + + return status_code + + +func delete_reactions(messageordict, custom_emoji = '') -> int: + assert(messageordict is Message or typeof(messageordict) == TYPE_DICTIONARY, 'Invalid type: Expected a Message or Dictionary') + if typeof(messageordict) == TYPE_DICTIONARY and messageordict.has('message_id'): + messageordict.id = messageordict.message_id + + var status_code + if custom_emoji != '': + custom_emoji = 'a:' + custom_emoji + status_code = await _send_get('/channels/%s/messages/%s/reactions/%s' % [messageordict.channel_id, messageordict.id, custom_emoji], HTTPClient.METHOD_DELETE, ['Content-Length:0']) + else: + status_code = await _send_get('/channels/%s/messages/%s/reactions' % [messageordict.channel_id, messageordict.id], HTTPClient.METHOD_DELETE, ['Content-Length:0']) + + return status_code + + +func get_reactions(messageordict, custom_emoji: String): + assert(Helpers.is_valid_str(custom_emoji), 'Invalid Type: custom_emoji must be a String') + custom_emoji = 'a:' + custom_emoji + assert(messageordict is Message or typeof(messageordict) == TYPE_DICTIONARY, 'Invalid type: Expected a Message or Dictionary') + if typeof(messageordict) == TYPE_DICTIONARY and messageordict.has('message_id'): + messageordict.id = messageordict.message_id + + var ret = await _send_get('/channels/%s/messages/%s/reactions/%s' % [messageordict.channel_id, messageordict.id, custom_emoji]) + return ret + +#endregion + + + +#region commands + +func register_command(command: ApplicationCommand, guild_id: String = '') -> ApplicationCommand: + var slug = '/applications/%s' % application.id + + if Helpers.is_valid_str(guild_id): + # Registering a guild command + slug += '/guilds/%s' % guild_id + + slug += '/commands' + var res = await _send_request(slug, command._to_dict(true)) + return ApplicationCommand.new(res) + + +func register_commands(commands: Array, guild_id: String = '') -> Array: + for i in range(len(commands)): + if commands[i] is ApplicationCommand: + commands[i] = commands[i]._to_dict(true) + + var slug = '/applications/%s' % application.id + + if Helpers.is_valid_str(guild_id): + # Registering guild commands + slug += '/guilds/%s' % guild_id + + slug += '/commands' + var res = await _send_request(slug, commands, HTTPClient.METHOD_PUT) + if typeof(res) == TYPE_ARRAY: + for i in range(len(res)): + res[i] = ApplicationCommand.new(res[i]) + return res + + +func delete_command(command_id: String, guild_id: String = '') -> int: + var slug = '/applications/%s' % application.id + + if Helpers.is_valid_str(guild_id): + # Deleting a guild command + slug += '/guilds/%s' % guild_id + + slug += '/commands/%s' % command_id + var res = await _send_get(slug, HTTPClient.METHOD_DELETE) + return res + + +func delete_commands(guild_id: String = '') -> int: + var slug = '/applications/%s' % application.id + + if Helpers.is_valid_str(guild_id): + # Deleting guild commands + slug += '/guilds/%s' % guild_id + + slug += '/commands' + var res = await _send_request(slug, [], HTTPClient.METHOD_PUT) + return res + + +func get_command(command_id: String, guild_id: String = '') -> ApplicationCommand: + var slug = '/applications/%s' % application.id + + if Helpers.is_valid_str(guild_id): + # Getting a guild command + slug += '/guilds/%s' % guild_id + + slug += '/commands/%s' % command_id + + var cmd = await _send_get(slug) + cmd = ApplicationCommand.new(cmd) + return cmd + + +func get_commands(guild_id: String = '') -> Array: + var slug = '/applications/%s' % application.id + + if Helpers.is_valid_str(guild_id): + # Getting guild commands + slug += '/guilds/%s' % guild_id + + slug += '/commands' + + var cmds = await _send_get(slug) + for i in range(len(cmds)): + cmds[i] = ApplicationCommand.new(cmds[i]) + return cmds + +#endregion + + +func set_presence(p_options: Dictionary) -> void: + """ + p_options { + status: String, text of the presence, + afk: bool, whether or not the client is afk, + + activity: { + type: String, type of the presence, + name: String, name of the presence, + url: String, url of the presence, + created_at: int, unix timestamp (in milliseconds) of when activity was added to user's session + } + } + """ + + var new_presence = {'status': 'online', 'afk': false, 'activity': {}} + + assert(p_options, 'Missing options for set_presence') + assert( + typeof(p_options) == TYPE_DICTIONARY, + 'Invalid Type: options in set_presence must be a Dictionary' + ) + + if p_options.has('status') and Helpers.is_valid_str(p_options.status): + assert( + str(p_options.status).to_upper() in PRESENCE_STATUS_TYPES, + 'Invalid Type: status must be one of PRESENCE_STATUS_TYPES' + ) + new_presence.status = p_options.status.to_lower() + if p_options.has('afk') and typeof(p_options.afk) == TYPE_BOOL: + new_presence.afk = p_options.afk + + # Check if an activity was passed + if p_options.has('activity') and typeof(p_options.activity) == TYPE_DICTIONARY: + if p_options.activity.has('name') and Helpers.is_valid_str(p_options.activity.name): + new_presence.activity.name = p_options.activity.name + + if p_options.activity.has('url') and Helpers.is_valid_str(p_options.activity.url): + new_presence.activity.url = p_options.activity.url + + if p_options.activity.has('created_at') and Helpers.is_num(p_options.activity.created_at): + new_presence.activity.created_at = p_options.activity.created_at + else: + new_presence.activity.created_at = Time.get_unix_time_from_system() * 1000 + + if p_options.activity.has('type') and Helpers.is_valid_str(p_options.activity.type): + assert( + str(p_options.activity.type).to_upper() in ACTIVITY_TYPES, + 'Invalid Type: type must be one of ACTIVITY_TYPES' + ) + new_presence.activity.type = ACTIVITY_TYPES[str(p_options.activity.type).to_upper()] + + _update_presence(new_presence) + + + +#region Inbuilt Functions + +func _ready() -> void: + randomize() + + # Generate needed nodes + _generate_timer_nodes() + + # Setup web socket client + _client = WebSocketPeer.new() + + $HeartbeatTimer.timeout.connect(_send_heartbeat) + + +func _process(_delta) -> void: + if not _client: + return + + _client.poll() + + var state = _client.get_ready_state() + + if state == WebSocketPeer.STATE_OPEN: + while _client.get_available_packet_count(): + var packet = _client.get_packet() + _data_received(packet.get_string_from_utf8()) + elif state == WebSocketPeer.STATE_CLOSED: + var code = _client.get_close_code() + var reason = _client.get_close_reason() + _connection_closed(code, reason) + set_process(false) # Stop processing. + +#endregion + + + +#region Private Functions + + +func _generate_timer_nodes() -> void: + var heart_beat_timer = Timer.new() + heart_beat_timer.name = 'HeartbeatTimer' + add_child(heart_beat_timer) + + var invalid_session_timer = Timer.new() + invalid_session_timer.name = 'InvalidSessionTimer' + add_child(invalid_session_timer) + + +func _connection_closed(code: int, reason: String) -> void: + if VERBOSE: + print('WSS connection closed with code=%s, reason=%s' % [code, reason]) + + +func _data_received(msg: String) -> void: + #if VERBOSE: + #print("Got packet: ", msg) + var data := msg + var dict = _jsonstring_to_dict(data) + var op = str(int(dict.op)) # OP Code Received + var d = dict.d # Data Received + + match op: + '10': + # Got hello + _setup_heartbeat_timer(d.heartbeat_interval) + + var response_d = {'op': -1} + if _sess_id: + # Resume session + response_d.op = 6 + response_d['d'] = {'token': TOKEN, 'session_id': _sess_id, 'seq': _last_seq} + else: + # Make new session + response_d.op = 2 + response_d['d'] = { + 'token': TOKEN, + 'intents': INTENTS, + 'properties': + {'$os': 'linux', '$browser': 'discord.gd', '$device': 'discord.gd'} + } + + _send_dict_wss(response_d) + '11': + # Heartbeat Acknowledged + _heartbeat_ack_received = true + if VERBOSE: + print('Heartbeat ack') + '9': + # Opcode 9 Invalid Session + _invalid_session_is_resumable = d + var timer = $InvalidSessionTimer + timer.one_shot = true + timer.wait_time = randi_range(1, 5) + timer.start() + '0': + # Event Dispatched + _handle_events(dict) + + +func _send_heartbeat() -> void: # Send heartbeat OP code 1 + if not _heartbeat_ack_received: + _client.close(1002) + return + + var response_payload = {'op': 1, 'd': _last_seq} + _send_dict_wss(response_payload) + _heartbeat_ack_received = false + if VERBOSE: + print('Heartbeat sent!') + + +func _handle_events(dict: Dictionary) -> void: + _last_seq = dict.s + var event_name = dict.t + + match event_name: + 'READY': + _sess_id = dict.d.session_id + var d = dict.d + + var _application = d.application + var _guilds = d.guilds + _clean_guilds(_guilds) + + var _user: User = User.new(self, d.user) + user = _user + application = _application + + for guild in _guilds: + guilds[guild.id] = guild + + 'GUILD_CREATE': + var guild = dict.d + _clean_guilds([guild]) + # Update number of cached guilds + if guild.has('lazy') and guild.lazy: + guilds_loaded += 1 + if guilds_loaded == guilds.size(): + bot_ready.emit(self) + + if not guilds.has(guild.id): + # Joined a new guild + guild_create.emit(self, guild) + + # Update cache + guilds[guild.id] = guild + + 'GUILD_UPDATE': + var guild = dict.d + _clean_guilds([guild]) + guilds[guild.id] = guild + guild_update.emit(self, guild) + + 'GUILD_DELETE': + var guild = dict.d + guilds.erase(guild.id) + guild_delete.emit(self, guild.id) + + # 'GUILD_MEMBER_ADD': + # print('-----------guild member add') + # var member = dict.d + # print(member) + + # 'GUILD_MEMBER_UPDATE': + # print('--------guild_member update') + # var data = dict.d + + # var guild = guilds[data.guild_id] + # data.erase('guild_id') + + # # Update users cache + # var user = data.user + # var user_id = user.id + # data.erase('user') + # users[user_id] = user + + # if data.has('pending'): + # var pending = data.pending + # data.erase('pending') + # data.is_pending = pending + # guild.members[user_id] = data + + # 'GUILD_MEMBER_DELETE': + # print('-----------guild member delete') + # var member = dict.d + # print(member) + + # 'GUILD_MEMBERS_CHUNK': + # print('-----------guild member chunk') + # var member = dict.d + # print(member) + + + 'RESUMED': + if VERBOSE: + print('Session Resumed') + + 'MESSAGE_CREATE': + var d = dict.d + + # Dont respond to webhooks + if d.has('webhook_id') and d.webhook_id: + return + + if d.has('sticker_items') and d.sticker_items and typeof(d.sticker_items) == TYPE_ARRAY: + if d.sticker_items.size() != 0: + return + + var coroutine = await _parse_message(d) + if coroutine == null: + # message might be a thread + # TODO: Handle sending messages in threads + return + + d = Message.new(d) + + var channel = channels.get(str(d.channel_id)) + message_create.emit(self, d, channel) + + 'MESSAGE_DELETE': + var d = dict.d + message_delete.emit(self, d) + + 'MESSAGE_REACTION_ADD': + var d = dict.d + + message_reaction_add.emit(self, d) + + 'MESSAGE_REACTION_REMOVE': + var d = dict.d + message_reaction_remove.emit(self, d) + + 'MESSAGE_REACTION_REMOVE_ALL': + var d = dict.d + message_reaction_remove_all.emit(self, d) + + 'MESSAGE_REACTION_REMOVE_EMOJI': + var d = dict.d + message_reaction_remove_emoji.emit(self, d) + + 'INTERACTION_CREATE': + var d = dict.d + + var id = d.id + var data = d.data + var token = d.token + + var interaction = await DiscordInteraction.new(self, d) + interaction_create.emit(self, interaction) + + +func _send_raw_request(slug: String, payload: Dictionary, method = HTTPClient.METHOD_POST): + var headers = _headers.duplicate(true) + var multipart_header = 'Content-Type: multipart/form-data; boundary="boundary"' + if headers.find(multipart_header) == -1: + headers.append(multipart_header) + + var http_client = HTTPClient.new() + + var body = PackedByteArray() + + # Add the payload_json to the form + body.append_array('--boundary\r\n'.to_utf8_buffer()) + body.append_array('Content-Disposition: form-data; name="payload_json"\r\n'.to_utf8_buffer()) + body.append_array('Content-Type: application/json\r\n\r\n'.to_utf8_buffer()) + + if payload.has('payload_json'): + body.append_array(JSON.stringify(payload.payload_json).to_utf8_buffer()) + elif payload.has('payload'): + body.append_array(JSON.stringify(payload.payload).to_utf8_buffer()) + + var count = 0 + for file in payload.files: + # Extract the name, media_type and data of each file + var file_name = file.name + var media_type = file.media_type + var data = file.data + # Add the file to the form + body.append_array('\r\n--boundary\r\n'.to_utf8_buffer()) + body.append_array( + ('Content-Disposition: form-data; name="file' + str(count) + '"; filename="' + file_name + '"').to_utf8_buffer() + ) + body.append_array(('\r\nContent-Type: ' + media_type + '\r\n\r\n').to_utf8_buffer()) + body.append_array(data) + count += 1 + + # End the form-data + body.append_array('\r\n--boundary--'.to_utf8_buffer()) + var err = http_client.connect_to_host(_https_domain) + assert(err == OK, 'Error connecting to Discord HTTPS server') + + while ( + http_client.get_status() == HTTPClient.STATUS_CONNECTING + or http_client.get_status() == HTTPClient.STATUS_RESOLVING + ): + http_client.poll() + await get_tree().process_frame + + assert( + http_client.get_status() == HTTPClient.STATUS_CONNECTED, + 'Could not connect to Discord HTTPS server' + ) + err = http_client.request_raw(method, _api_slug + slug, headers, body) + + while http_client.get_status() == HTTPClient.STATUS_REQUESTING: + http_client.poll() + await get_tree().process_frame + + # Request is made, now extract the reponse body + assert( + ( + http_client.get_status() == HTTPClient.STATUS_BODY + or http_client.get_status() == HTTPClient.STATUS_CONNECTED + ) + ) + + if http_client.has_response(): + headers = http_client.get_response_headers_as_dictionary() + + var rb = PackedByteArray() + while http_client.get_status() == HTTPClient.STATUS_BODY: + # While there is body left to be read + http_client.poll() + var chunk = http_client.read_response_body_chunk() + if chunk.size() == 0: + # Got nothing, wait for buffers to fill a bit. + OS.delay_usec(1000) + else: + rb = rb + chunk # Append to read buffer. + + var response = _jsonstring_to_dict(rb.get_string_from_utf8()) + if response == null: + if http_client.get_response_code() == 204: + return true + return false + if response.has('code'): + print('Response: status code ', str(http_client.get_response_code())) + print(JSON.stringify(response, '\t')) + + assert(not response.has('code'), 'Error sending request. See output window') + + if response.has('retry_after'): + # We got ratelimited + await get_tree().create_timer(int(response.retry_after)).timeout + response = await _send_raw_request(slug, payload, method) + + return response + else: + assert(false, 'Unable to upload file. Got empty response from server') + + +func _send_request(slug: String, payload, method = HTTPClient.METHOD_POST): + var headers = _headers.duplicate(true) + + var json_header = 'Content-Type: application/json' + if headers.find(json_header) == -1: + headers.append(json_header) + + var http_request = HTTPRequest.new() + add_child(http_request) + http_request.request.call_deferred( + _https_base + slug, headers, method, JSON.stringify(payload) + ) + + var data = await http_request.request_completed + http_request.queue_free() + + # Check for errors + assert(data[0] == HTTPRequest.RESULT_SUCCESS, 'Error sending request: HTTP Failed') + var response = _jsonstring_to_dict(data[3].get_string_from_utf8()) + if response == null: + if data[1] == 204: + return true + return false + + if response and response.has('code'): + # Got an error + print('Response: status code ', str(data[1])) + print('Error: ' + JSON.stringify(response, '\t')) + + if method != HTTPClient.METHOD_DELETE: + if response.has('code'): + push_error('Error sending request. See output window') + + if response.has('retry_after'): + # We got ratelimited + await get_tree().create_timer(int(response.retry_after)).timeout + response = await _send_request(slug, payload, method) + + return response + + +func _get_dm_channel(channel_id: String) -> Dictionary: + assert(Helpers.is_valid_str(channel_id), 'Invalid Type: channel_id must be a valid String') + var data = await _send_get('/channels/%s' % channel_id) + if typeof(data) == TYPE_DICTIONARY: + _clean_channel(data) + return data + + +func _send_get(slug, method = HTTPClient.METHOD_GET, additional_headers = []): + var http_request = HTTPRequest.new() + add_child(http_request) + + var headers = _headers + additional_headers + http_request.request.call_deferred(_https_base + slug, headers, method) + + var data = await http_request.request_completed + http_request.queue_free() + + assert(data[0] == HTTPRequest.RESULT_SUCCESS) + if method == HTTPClient.METHOD_GET: + var response = _jsonstring_to_dict(data[3].get_string_from_utf8()) + if response != null and response.has('code'): + # Got an error + print('GET: status code ', str(data[1])) + print('Error sending GET request: ' + JSON.stringify(response, '\t')) + push_error('Error sending GET request. See output window') + return response + + else: # Maybe a PUT/DELETE for reaction + return data[1] + + +func _send_get_cdn(slug) -> PackedByteArray: + var http_request = HTTPRequest.new() + add_child(http_request) + + if slug.find('/') == 0: + http_request.request(_cdn_base + slug, _headers) + else: + http_request.request(slug, _headers) + + var data = await http_request.request_completed + http_request.queue_free() + + # Check for errors + assert(data[0] == HTTPRequest.RESULT_SUCCESS, 'Error sending GET cdn request: HTTP Failed') + + if data[1] != 200: + print('HTTPS GET cdn Error: Status Code: %s' % data[1]) + assert(data[1] == 200, 'HTTPS GET cdn Error: Status code ' + str(data[1])) + + return data[3] + + +func _send_message_request( + messageorchannelid, content, options := {}, method := HTTPClient.METHOD_POST +): + var payload = { + 'content': null, + 'tts': false, + 'embeds': null, + 'components': null, + 'allowed_mentions': null, + 'message_reference': null + } + + var slug + if messageorchannelid is Message: + slug ='/channels/%s/messages' % str(messageorchannelid.channel_id) + else: + assert(messageorchannelid.length() > 16, 'channel_id is not valid') + slug = '/channels/%s/messages' % str(messageorchannelid) + + # Handle edit message or delete message + if method == HTTPClient.METHOD_PATCH or method == HTTPClient.METHOD_DELETE: + slug += '/' + str(messageorchannelid.id) + + if method == HTTPClient.METHOD_PATCH: + if typeof(messageorchannelid) == TYPE_OBJECT and typeof(messageorchannelid.attachments) == TYPE_ARRAY: + if messageorchannelid.attachments.size() == 0: + payload.attachments = null + else: + # Add the attachments to keep to the payload + payload.attachments = messageorchannelid.attachments + + # Check if the content is only a string + if typeof(content) == TYPE_STRING and content.length() > 0: + assert(content.length() <= 2048, 'Message content must be less than 2048 characters') + payload.content = content + + elif typeof(content) == TYPE_DICTIONARY: # Check if the content is the options dictionary + options = content + content = null + + # Parse the options + if typeof(options) == TYPE_DICTIONARY: + """parse the message options - refer https://discord.com/developers/docs/resources/channel#create-message-jsonform-params + options { + tts: bool, + embeds: Array, + components: Array, + files: Array, + allowed_mentions: object, + message_reference: object, + } + """ + + if options.has('content') and Helpers.is_str(options.content): + assert( + options.content.length() <= 2048, + 'Message content must be less than 2048 characters' + ) + payload.content = options.content + + if options.has('tts') and options.tts: + payload.tts = true + + if options.has('embeds') and options.embeds.size() > 0: + for embed in options.embeds: + if embed is Embed: + if payload.embeds == null: + payload.embeds = [] + payload.embeds.append(embed._to_dict()) + + if options.has('components') and options.components.size() > 0: + assert( + options.components.size() <= 5, + 'Message can have a max of 5 MessageActionRow components.' + ) + for component in options.components: + assert( + component is MessageActionRow, 'Parent component must be a MessageActionRow.' + ) + if payload.components == null: + payload.components = [] + payload.components.append(component._to_dict()) + + if options.has('allowed_mentions') and options.allowed_mentions: + if typeof(options.allowed_mentions) == TYPE_DICTIONARY: + """ + allowedMentions { + parse: array of mention types ['roles', 'users', 'everyone'] + roles: array of role_ids + users: array of user_ids + replied_user: bool, whether to mention author of msg + } + """ + payload.allowed_mentions = options.allowed_mentions + + if options.has('message_reference') and options.message_reference: + """ + message_reference { + message_id: id of originating msg, + channel_id? *: optional + guild_id?: optional + fail_if_not_exists?: bool, whether to error + } + """ + payload.message_reference = options.message_reference + + if options.has('files') and options.files: + assert( + typeof(options.files) == TYPE_ARRAY, + 'Invalid Type: files in message options must be an array' + ) + + if options.files.size() > 0: + # Loop through each file + for file in options.files: + assert( + file.has('name') and Helpers.is_valid_str(file.name), + 'Missing name for file in files' + ) + assert( + file.has('media_type') and Helpers.is_valid_str(file.media_type), + 'Missing media_type for file in files' + ) + assert(file.has('data') and file.data, 'Missing data for file in files') + assert( + file.data is PackedByteArray, + 'Invalid Type: data of file in files must be PackedByteArray' + ) + + var json_payload = payload.duplicate(true) + var new_payload = {'files': options.files, 'payload_json': json_payload} + payload = new_payload + + var res + if payload.has('files') and payload.files and typeof(payload.files) == TYPE_ARRAY: + # Send raw post request using multipart/form-data + var coroutine = await _send_raw_request(slug, payload, method) + if typeof(coroutine) == TYPE_OBJECT: + res = await coroutine + else: + res = coroutine + + else: + res = await _send_request(slug, payload, method) + + if method == HTTPClient.METHOD_DELETE: + return res + else: + await _parse_message(res) + + if res.has("code") and res.has("errors"): + # its an error + return res + + var msg = Message.new(res) + return msg + + +func _update_presence(new_presence: Dictionary) -> void: + var status = new_presence.status + var activity = new_presence.activity + + var response_d = { + 'op': 3, # Presence update + } + response_d['d'] = { + 'since': new_presence if new_presence.has('since') else null, + 'status': new_presence.status, + 'afk': new_presence.afk, + 'activities': [new_presence.activity] + } + _send_dict_wss(response_d) + + +# Helper functions +func _jsonstring_to_dict(data: String): + var json = JSON.new() + var result = json.parse(data) + + if not data: + return {} + + if result != OK: + if VERBOSE: + print("Failed to parse json: error at line %s with msg %s for data %s" % [json.get_error_line(), json.get_error_message(), data]) + return {} + + return json.data + + +func _setup_heartbeat_timer(interval: int) -> void: + # Setup heartbeat timer and start it + _heartbeat_interval = int(interval) / 1000 + var timer = $HeartbeatTimer + timer.wait_time = _heartbeat_interval + timer.start() + + +func _send_dict_wss(d: Dictionary) -> void: + var payload = JSON.stringify(d) + var err = _client.put_packet(payload.to_utf8_buffer()) + if OK != err: + if VERBOSE: + print("Failed to send packet: error=%s (%s)" % [error_string(err), err]) + + +func _clean_guilds(guilds: Array) -> void: + for guild in guilds: + # Converts the unavailable property to available + if guild.has('unavailable'): + guild.available = not guild.unavailable + else: + guild.available = true + guild.erase('unavailable') + + if guild.has('channels'): + for channel in guild.channels: + _clean_channel(channel) + channel.guild_id = guild.id + channels[channel.id] = channel + + if guild.has('members') and typeof(guild.members) == TYPE_ARRAY: + # Parse the guild members + var members = {} + for member in guild.members: + var member_id = member.user.id + users[member_id] = member.user + member.erase('user') + members[member_id] = member + guild.members = members + + if guild.has('roles') and typeof(guild.roles) == TYPE_ARRAY: + # Parse the guild roles + var roles = {} + for role in guild.roles: + var role_id = role.id + role.erase('id') + roles[role_id] = role + guild.roles = roles + + +func _clean_channel(channel: Dictionary) -> void: + _float_to_int(channel, "type") + _float_to_int(channel, "flags") + if channel.has('type') and typeof(channel.type) == TYPE_INT: + channel.type = CHANNEL_TYPES.get(str(channel.type)) + + +func _parse_message(message): + if typeof(message) == TYPE_OBJECT and message is Message: + return message + + if typeof(message) != TYPE_DICTIONARY: + printerr("_parse_message error: Type of message must be dictionary") + return null + + _float_to_int(message, "type") + _float_to_int(message, "flags") + + if message.has('channel_id') and message.channel_id: + # Check if channel is cached + var channel = channels.get(str(message.channel_id)) + + if not channel: + # Try to check if it is a DM channel + if VERBOSE: + print('Fetching DM channel: %s from api' % message.channel_id) + + channel = await _get_dm_channel(message.channel_id) + _clean_channel(channel) + + if channel and channel.has('type') and channel.type == 'DM': + channels[str(message.channel_id)] = channel + else: + # not a valid channel, it might be a thread + return null + + if message.has('author') and typeof(message.author) == TYPE_DICTIONARY: + # get the cached author of the message + message.author = User.new(self, message.author) + + return 1 + + +func _float_to_int(dict, key): + if dict.has(key) and typeof(dict[key]) == TYPE_FLOAT: + dict[key] = int(dict[key]) + +#endregion diff --git a/addons/discord_gd/discord.gd.uid b/addons/discord_gd/discord.gd.uid new file mode 100644 index 0000000..ddfe311 --- /dev/null +++ b/addons/discord_gd/discord.gd.uid @@ -0,0 +1 @@ +uid://dqss6smw7w8nh diff --git a/addons/discord_gd/plugin.cfg b/addons/discord_gd/plugin.cfg new file mode 100644 index 0000000..6dd0d2b --- /dev/null +++ b/addons/discord_gd/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="discord.gd" +description="A Discord bot API wrapper for Godot." +author="Delano Lourenco" +version="2.0.0" +script="plugin.gd" diff --git a/addons/discord_gd/plugin.gd b/addons/discord_gd/plugin.gd new file mode 100644 index 0000000..d3e6204 --- /dev/null +++ b/addons/discord_gd/plugin.gd @@ -0,0 +1,9 @@ +@tool +extends EditorPlugin + +""" +Discord.gd + +Nothing to do here, +check discord.gd script file +""" diff --git a/addons/discord_gd/plugin.gd.uid b/addons/discord_gd/plugin.gd.uid new file mode 100644 index 0000000..6b212f4 --- /dev/null +++ b/addons/discord_gd/plugin.gd.uid @@ -0,0 +1 @@ +uid://h5i4g1mvqc8u diff --git a/application_cmds/test_appcmd.gd b/application_cmds/test_appcmd.gd new file mode 100644 index 0000000..ccae219 --- /dev/null +++ b/application_cmds/test_appcmd.gd @@ -0,0 +1,18 @@ +extends RefCounted + +#func on_ready(main, bot: DiscordBot) -> void: +# pass +# +#func on_autocomplete(main, bot: DiscordBot, interaction: DiscordInteraction, options: Array) -> void: +# pass + +func execute(main, bot: DiscordBot, interaction: DiscordInteraction, options: Array) -> void: + print("hiya!") + interaction.reply( + { + "content" : "meow meow! hearing you loud and clear tabby!" + } + ) + pass + +var data = ApplicationCommand.new().set_name("meow_test").set_description("meow_desc") diff --git a/application_cmds/test_appcmd.gd.uid b/application_cmds/test_appcmd.gd.uid new file mode 100644 index 0000000..e52e5aa --- /dev/null +++ b/application_cmds/test_appcmd.gd.uid @@ -0,0 +1 @@ +uid://d3dwi5xxpdp2x diff --git a/datatypes/librarySave.gd b/datatypes/librarySave.gd new file mode 100644 index 0000000..eba4b2e --- /dev/null +++ b/datatypes/librarySave.gd @@ -0,0 +1,5 @@ +extends Resource +class_name LibrarySave + +@export var spools : Array[Spool] +@export var printers : Array[Printer] diff --git a/datatypes/librarySave.gd.uid b/datatypes/librarySave.gd.uid new file mode 100644 index 0000000..bcb6076 --- /dev/null +++ b/datatypes/librarySave.gd.uid @@ -0,0 +1 @@ +uid://c634rg4aki0oy diff --git a/datatypes/printer.gd b/datatypes/printer.gd new file mode 100644 index 0000000..399a7c6 --- /dev/null +++ b/datatypes/printer.gd @@ -0,0 +1,2 @@ +extends Resource +class_name Printer diff --git a/datatypes/printer.gd.uid b/datatypes/printer.gd.uid new file mode 100644 index 0000000..de011db --- /dev/null +++ b/datatypes/printer.gd.uid @@ -0,0 +1 @@ +uid://bnyoyarpn5qml diff --git a/datatypes/spool.gd b/datatypes/spool.gd new file mode 100644 index 0000000..9a5db1d --- /dev/null +++ b/datatypes/spool.gd @@ -0,0 +1,7 @@ +extends Resource +class_name Spool + +@export var name : String +@export var material : String +@export var link : String +# tags? diff --git a/datatypes/spool.gd.uid b/datatypes/spool.gd.uid new file mode 100644 index 0000000..4cf2b1c --- /dev/null +++ b/datatypes/spool.gd.uid @@ -0,0 +1 @@ +uid://daj15kpbagd33 diff --git a/demo.tscn b/demo.tscn new file mode 100644 index 0000000..3c596dc --- /dev/null +++ b/demo.tscn @@ -0,0 +1,29 @@ +[gd_scene load_steps=4 format=3 uid="uid://dyob0p2l7g5fj"] + +[ext_resource type="Script" uid="uid://bv8cjh12rwg4t" path="res://test.gd" id="1_0bhed"] +[ext_resource type="Script" uid="uid://dqss6smw7w8nh" path="res://addons/discord_gd/discord.gd" id="2_m0rpm"] +[ext_resource type="Texture2D" uid="uid://dta0nr1cvl70v" path="res://fabcatserver.png" id="3_m0rpm"] + +[node name="Control" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_0bhed") + +[node name="DiscordBot" type="Node" parent="."] +script = ExtResource("2_m0rpm") +metadata/_custom_type_script = "uid://dqss6smw7w8nh" + +[node name="TextureRect" type="TextureRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +texture = ExtResource("3_m0rpm") +expand_mode = 3 +stretch_mode = 5 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..44da1fc --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,42 @@ +[preset.0] + +name="Linux" +platform="Linux" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="../../Exports/Fabcat/FabsocBot.x86_64" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +shader_baker/enabled=false +binary_format/architecture="x86_64" +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="#!/usr/bin/env bash +export DISPLAY=:0 +unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\" +\"{temp_dir}/{exe_name}\" {cmd_args}" +ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash +kill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\") +rm -rf \"{temp_dir}\"" diff --git a/fabcatserver.png b/fabcatserver.png new file mode 100644 index 0000000..7dff896 Binary files /dev/null and b/fabcatserver.png differ diff --git a/fabcatserver.png.import b/fabcatserver.png.import new file mode 100644 index 0000000..1f2a12a --- /dev/null +++ b/fabcatserver.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dta0nr1cvl70v" +path="res://.godot/imported/fabcatserver.png-5b3643fea72e10ebca565cbc1124bb9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://fabcatserver.png" +dest_files=["res://.godot/imported/fabcatserver.png-5b3643fea72e10ebca565cbc1124bb9e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..5f75feb --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4bahh7qjl64" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/library.gd b/library.gd new file mode 100644 index 0000000..06fbb3e --- /dev/null +++ b/library.gd @@ -0,0 +1,40 @@ +extends Node + +var save_path : String = "user://librarySave.tres" +var save : LibrarySave + + +# Called when the node enters the scene tree for the first time. +func _ready() -> void: + load_data() + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta: float) -> void: + pass + +func load_data(): + # check if save file exists + print("real? " + str(FileAccess.file_exists(save_path))) + if FileAccess.file_exists(save_path): + print("yes, loading...") + save = ResourceLoader.load(save_path) as LibrarySave + print(save) + #save = load(save_path) as GameSave + + + else: + print("nope, creating...") + save_new() + pass + +func save_data(): + ResourceSaver.save(save, save_path) + pass + +func save_new(): + #data = FileAccess.open(save_path, FileAccess.WRITE_READ) + save = LibrarySave.new() + var error = ResourceSaver.save(save, save_path) + print(error) + diff --git a/library.gd.uid b/library.gd.uid new file mode 100644 index 0000000..5fa26b1 --- /dev/null +++ b/library.gd.uid @@ -0,0 +1 @@ +uid://hkh1ewsuji8m diff --git a/main.gd b/main.gd new file mode 100644 index 0000000..c0c5211 --- /dev/null +++ b/main.gd @@ -0,0 +1,205 @@ +extends Node +signal interaction_create(world, bot, interaction, data) + +#const prefix = "gd." + +var interactions = {} +var application_commands = {} + + +#func _load_bot_token() -> String: + ## read from .env file DISORD_BOT_TOKEN + #var lines = FileAccess.get_file_as_string(".env").split("\n") + #for line in lines: + #if line.begins_with("DISCORD_BOT_TOKEN="): + #return line.split("=")[1] + #return "" + +func _ready() -> void: + var bot = DiscordBot.new() + add_child(bot) + + # Try to read token from global environment variables + var token = OS.get_environment("DISCORD_BOT_TOKEN") + if not token or (token and len(token) < 10): + # Read token from local .env file + #token = _load_bot_token() + token = "MTQzMzAyNTMwMzYzODQ0MjAzNQ.G3r4My.IdnvCw6xTBfoitEzvhPgxeErSDgcMCsznmLnvI" + + bot.TOKEN = token + #bot.INTENTS = 4609 + bot.bot_ready.connect(_on_bot_ready) + #bot.message_create.connect(_on_message_create) + bot.interaction_create.connect(_on_interaction_create) + bot.login() + #_load_commands(bot) + _load_application_commands(bot) + +func _on_bot_ready(bot: DiscordBot): + print("Logged in as " + bot.user.username + "#" + bot.user.discriminator) + bot.set_presence({ + "activity": { + "type": "Watching", + "name": "Watching the printers" + } + }) + + # Registering commands is not needed on every run, + # only register the command if you change any options + # or if you add/remove commands + + # For development use the single server command and + # once you have the commands working you can register + # them as global commands + + # -----Single server (updates instantly) + #_register_application_commands(bot, "guild_id_here") + _register_application_commands(bot, "679917161195765822") + + # -----Global (may take upto 1hr to update) + #_register_application_commands(bot) + + +#func _on_message_create(bot: DiscordBot, message: Message, channel: Dictionary) -> void: + #if message.author.bot or not message.content.begins_with(prefix): + #return +# + ## Make sure to use trim_prefix() instead of lstrip() + #var raw_content = message.content.trim_prefix(prefix) + #var tokens = [] + #var r = RegEx.new() + #r.compile("\\S+") # Negated whitespace character class + #for token in r.search_all(raw_content): + #tokens.append(token.get_string()) + #var cmd_or_alias = tokens[0].to_lower() + #tokens.remove(0) # Remove the command name from the tokens + #var args = tokens + #_handle_command(bot, message, channel, cmd_or_alias, args) + + +#func _load_commands(bot: DiscordBot) -> void: + #var cmd_path = "res://cmds/" + #var dir = DirAccess.open(cmd_path) + #if not dir: + #push_error("An error occurred when trying to open /cmds/ folder.") + #return +# + #dir.list_dir_begin() + #while true: + #var file = dir.get_next() + #if file == "": # End of files + #break + #elif not file.begins_with(".") and (file.ends_with(".gd") or file.ends_with(".gdc")): + #var script = load(cmd_path + file.get_basename() + ".gd").new() + #var data = script.help +# + ## Ensure that the commands don't have the default help values + #assert(data.name != "test_name" and data.category != "test_category" and data.description != "test_description" and script.get_usage(prefix) != "test usage", "Must change default values for Command in " + file) +# + #if not data.enabled: + #continue +# + #if script.has_method("on_ready"): + #script.on_ready(self, bot) +# + #commands[data.name] = script +# + #if not data.has("aliases"): + #continue +# + #for alias in data.aliases: + #assert(not command_aliases.has(alias), "Duplicate cmd aliases found in cmd: " + file) +# + #command_aliases[alias] = data.name +# + #print("Loaded " + str(commands.size()) + " cmds") + #dir.list_dir_end() + +func _load_application_commands(bot: DiscordBot) -> void: + var app_cmd_path = "res://application_cmds/" + var dir = DirAccess.open(app_cmd_path) + + if not dir: + push_error("An error occurred when trying to open /application_cmds/ folder.") + return + + dir.list_dir_begin() + while true: + var file = dir.get_next() + if file == "": # End of files + break + elif not file.begins_with(".") and (file.ends_with(".gd") or file.ends_with(".gdc")): + var script = load(app_cmd_path + file.get_basename() + ".gd").new() + var data = script.data + + # Ensure that the commands don't have the default help values + assert(data.name != "test_name" and data.description != "test_description", "Must change default values for ApplicationCommand in " + file) + + if script.has_method("on_ready"): + script.on_ready(self, bot) + + application_commands[data.name] = script + + print("Loaded " + str(application_commands.size()) + " application cmds") + dir.list_dir_end() + +func _register_application_commands(bot, guild_id: String = "") -> void: + var application_commands_data = [] + for app_cmd in application_commands.values(): + application_commands_data.append(app_cmd.data) + + bot.register_commands(application_commands_data, guild_id) + +#func _handle_command(bot: DiscordBot, message: Message, channel: Dictionary, cmd_or_alias: String, args: Array): + #var cmd = null + #if command_aliases.has(cmd_or_alias): + #cmd = commands[command_aliases[cmd_or_alias]] + #elif commands.has(cmd_or_alias): + #cmd = commands[cmd_or_alias] +# + #if cmd == null: + #return +# + #print("CMD: " + cmd.help.name + " by " + message.author.username + "#" + message.author.discriminator + " (" + message.author.id + ")") +# + #cmd.on_message(self, bot, message, channel, args) + +func remove_components_from_interaction(interaction: DiscordInteraction, msg = ":robot: Components have timed out!") -> void: + var embed = Embed.new().set_description(msg) + var new_embeds = interaction.message.embeds + [embed] + interaction.update({ + "content": interaction.message.content, + "embeds": new_embeds, + "components": [] + }) + +func _on_interaction_create(bot: DiscordBot, interaction: DiscordInteraction): + # Handle ApplicationCommand + if interaction.is_command() or interaction.is_autocomplete(): + var cmd_name = interaction.data.name + if application_commands.has(cmd_name): + # The application command was found, so execute it + var app_cmd = application_commands[cmd_name] + var options = interaction.data.options if interaction.data.has("options") else [] + + if interaction.is_autocomplete(): + # It is an autocomplete command + app_cmd.on_autocomplete(self, bot, interaction, options) + return + + print("APP_CMD: " + app_cmd.data.name + " by " + interaction.member.user.username + "#" + interaction.member.user.discriminator + " (" + interaction.member.user.id + ")") + app_cmd.execute(self, bot, interaction, options) + else: + # The application command was not found + interaction.reply({ + "ephemeral": true, + "content": ":electric_plug: The requested command was not found." + }) + return + + var msg_id = interaction.message.id + # Emit the interaction to the normal (text) commands + if interactions.has(msg_id): + emit_signal("interaction_create", self, bot, interaction, interactions[msg_id]) + else: + remove_components_from_interaction(interaction) diff --git a/main.gd.uid b/main.gd.uid new file mode 100644 index 0000000..b362105 --- /dev/null +++ b/main.gd.uid @@ -0,0 +1 @@ +uid://cus8nh0g3yyj2 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..c0e9473 --- /dev/null +++ b/project.godot @@ -0,0 +1,29 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="FabsocBot" +run/main_scene="uid://dyob0p2l7g5fj" +config/features=PackedStringArray("4.5", "GL Compatibility") +config/icon="uid://dta0nr1cvl70v" + +[autoload] + +Library="*res://Library.tscn" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/discord_gd/plugin.cfg") + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/templates/application_command.gd b/templates/application_command.gd new file mode 100644 index 0000000..14b9b45 --- /dev/null +++ b/templates/application_command.gd @@ -0,0 +1,12 @@ +extends RefCounted + +#func on_ready(main, bot: DiscordBot) -> void: +# pass +# +#func on_autocomplete(main, bot: DiscordBot, interaction: DiscordInteraction, options: Array) -> void: +# pass + +func execute(main, bot: DiscordBot, interaction: DiscordInteraction, options: Array) -> void: + pass + +var data = ApplicationCommand.new().set_name("test_name").set_description("test_description") diff --git a/templates/application_command.gd.uid b/templates/application_command.gd.uid new file mode 100644 index 0000000..a79b69e --- /dev/null +++ b/templates/application_command.gd.uid @@ -0,0 +1 @@ +uid://db2x4erxyi6sw diff --git a/test.gd b/test.gd new file mode 100644 index 0000000..59cd259 --- /dev/null +++ b/test.gd @@ -0,0 +1,38 @@ +extends Control + +func _ready(): + var discord_bot = $DiscordBot + discord_bot.TOKEN = "MTQzMzAyNTMwMzYzODQ0MjAzNQ.G3r4My.IdnvCw6xTBfoitEzvhPgxeErSDgcMCsznmLnvI" + discord_bot.login() + discord_bot.bot_ready.connect(_on_DiscordBot_bot_ready) + discord_bot.message_create.connect(_on_DiscordBot_message_create) + +func _on_DiscordBot_bot_ready(bot: DiscordBot): + print("Logged in as %s#%s" % [bot.user.username, bot.user.discriminator]) + print("Listening on %d channels and %d guilds." % [bot.channels.size(), bot.guilds.size()]) + +func _on_DiscordBot_message_create(bot: DiscordBot, msg: Message, channel: Dictionary): + print("New message from %s: %s" % [msg.author.username, msg.content]) + + if msg.author.bot: + return + + send_screenshot(bot, msg) + + await bot.reply(msg, "meow!") + +func send_screenshot(bot: DiscordBot, msg: Message): + var image : Image = get_viewport().get_texture().get_image() + #image.flip_y() + var bytes = image.save_png_to_buffer() + + #send + bot.send(msg, { + "files":[ + { + "name" : "screenshot.png", + "media_type" : "image/png", + "data" : bytes + } + ] + }) diff --git a/test.gd.uid b/test.gd.uid new file mode 100644 index 0000000..3cf3012 --- /dev/null +++ b/test.gd.uid @@ -0,0 +1 @@ +uid://bv8cjh12rwg4t