FabcatBot/addons/discord_gd/discord.gd

1318 lines
38 KiB
GDScript3
Raw Permalink Normal View History

2025-10-29 22:23:34 +11:00
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
2025-11-03 01:38:28 +11:00
var VERBOSE: bool = true
2025-10-29 22:23:34 +11:00
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 timeout_member(guild_id: String, user_id: String, until : String):
var res = await _send_request('/guilds/%s/members/%s' % [guild_id, user_id], {'communication_disabled_until' = '2025-12-5T09:49:58.965896+00:00'}, HTTPClient.METHOD_PATCH)
return res
2026-01-03 18:06:32 +11:00
func delete_message(channel_id : String, message_id : String):
var res = await _send_request('/channels/%s/messages/%s' % [channel_id, message_id], {}, HTTPClient.METHOD_DELETE)
return res
2025-10-29 22:23:34 +11:00
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])
2025-11-05 17:44:12 +11:00
get_tree().quit() #force reset
2025-10-29 22:23:34 +11:00
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}
2025-11-03 01:38:28 +11:00
if _client.get_ready_state() == WebSocketPeer.State.STATE_OPEN:
_send_dict_wss(response_payload)
else:
2025-11-05 17:44:12 +11:00
print("Websocket not open, resseting self")
get_tree().quit()
#login()
2025-10-29 22:23:34 +11:00
_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
2025-11-07 15:05:39 +11:00
await _parse_message(d)
2025-10-29 22:23:34 +11:00
2025-11-07 15:05:39 +11:00
d = Message.new(d, self)
2025-10-29 22:23:34 +11:00
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:
2025-11-07 15:05:39 +11:00
if _client.get_ready_state() != WebSocketPeer.STATE_OPEN:
if VERBOSE:
print("Failed to send packet as websocket state is not open. Got state: " + str(_client.get_ready_state()))
return
2025-10-29 22:23:34 +11:00
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)
2025-11-07 15:05:39 +11:00
channels[str(message.channel_id)] = channel
2025-10-29 22:23:34 +11:00
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