Files
BongaTheProto 8bf3366f57 You're NOT escaping the discord verif
Not even TG's systems can save you now!

Funnily enough the discord subsystem is from TG as well xd
2022-06-05 02:12:38 -05:00

328 lines
12 KiB
Plaintext

/**
* # Discord Subsystem
*
* This subsystem handles some integrations with discord
*
*
* NOTES:
* * There is a DB table to track ckeys and associated discord IDs. (discord_link)
* * This system REQUIRES TGS for notifying users at end of the round
* * The SS uses fire() instead of just pure shutdown, so people can be notified if it comes back after a crash, where the SS wasn't properly shutdown
* * It only writes to the disk every 5 minutes, and it won't write to disk if the file is the same as it was the last time it was written. This is to save on disk writes
* * The system is kept per-server (EG: Terry will not notify people who pressed notify on Sybil), but the accounts are between servers so you dont have to relink on each server.
*
*
* ## HOW NOTIFYING WORKS
*
* ### ROUNDSTART:
* 1) The file is loaded and the discord IDs are extracted
* 2) A ping is sent to the discord with the IDs of people who wished to be notified
* 3) The file is emptied
*
* ### MIDROUND:
* 1) Someone usees the notify verb, it adds their discord ID to the list.
* 2) On fire, it will write that to the disk, as long as conditions above are correct
*
* ### END ROUND:
* 1) The file is force-saved, incase it hasn't fired at end round
*
* This is an absolute clusterfuck, but its my clusterfuck -aa07
*/
SUBSYSTEM_DEF(discord)
name = "Discord"
wait = 3000
init_order = INIT_ORDER_DISCORD
/// People to save to notify file
var/list/notify_members = list()
/// Copy of previous list, so the SS doesnt have to fire if no new members have been added
var/list/notify_members_cache = list()
/// People to notify on roundstart
var/list/people_to_notify = list()
/// People who have tried to verify this round already
var/list/reverify_cache
/// Common words list, used to generate one time tokens
var/list/common_words
/// The file where notification status is saved
var/notify_file = file("data/notify.json")
/// Is TGS enabled (If not we won't fire because otherwise this is useless)
var/enabled = FALSE
/datum/controller/subsystem/discord/Initialize(start_timeofday)
common_words = world.file2list("strings/1000_most_common.txt")
reverify_cache = list()
// Check for if we are using TGS, otherwise return and disables firing
if(world.TgsAvailable())
enabled = TRUE // Allows other procs to use this (Account linking, etc)
else
can_fire = FALSE // We dont want excess firing
return ..() // Cancel
try
people_to_notify = json_decode(file2text(notify_file))
catch
pass() // The list can just stay as its default (blank). Pass() exists because it needs a catch
var/notifymsg = jointext(people_to_notify, ", ")
if(notifymsg)
notifymsg += ", a new round is starting!"
send2chat(trim(notifymsg), CONFIG_GET(string/chat_new_game_notifications)) // Sends the message to the discord, using same config option as the roundstart notification
fdel(notify_file) // Deletes the file
delete_nulls() // Deletes nulls in the database
return ..()
/datum/controller/subsystem/discord/fire()
if(!enabled)
return // Dont do shit if its disabled
if(notify_members == notify_members_cache)
return // Dont re-write the file
// If we are all clear
write_notify_file()
/datum/controller/subsystem/discord/Shutdown()
write_notify_file() // Guaranteed force-write on server close
/datum/controller/subsystem/discord/proc/write_notify_file()
if(!enabled) // Dont do shit if its disabled
return
fdel(notify_file) // Deletes the file first to make sure it writes properly
WRITE_FILE(notify_file, json_encode(notify_members)) // Writes the file
notify_members_cache = notify_members // Updates the cache list
/**
* Given a ckey, look up the discord user id attached to the user, if any
*
* This gets the most recent entry from the discord link table that is associated with the given ckey
*
* Arguments:
* * lookup_ckey A string representing the ckey to search on
*/
/datum/controller/subsystem/discord/proc/lookup_id(lookup_ckey)
var/datum/discord_link_record/link = find_discord_link_by_ckey(lookup_ckey)
if(link)
return link.discord_id
/**
* Given a discord id as a string, look up the ckey attached to that account, if any
*
* This gets the most recent entry from the discord_link table that is associated with this discord id snowflake
*
* Arguments:
* * lookup_id The discord id as a string
*/
/datum/controller/subsystem/discord/proc/lookup_ckey(lookup_id)
var/datum/discord_link_record/link = find_discord_link_by_discord_id(lookup_id)
if(link)
return link.ckey
/datum/controller/subsystem/discord/proc/get_or_generate_one_time_token_for_ckey(ckey)
// Is there an existing valid one time token
var/datum/discord_link_record/link = find_discord_link_by_ckey(ckey, timebound = TRUE)
if(link && link.valid)
return link.one_time_token
// Otherwise we make one
return generate_one_time_token(ckey)
/**
* Generate a timebound token for discord verification
*
* This uses the common word list to generate a six word random token, this token can then be fed to a discord bot that has access
* to the same database, and it can use it to link a ckey to a discord id, with minimal user effort
*
* It returns the token to the calling proc, after inserting an entry into the discord_link table of the following form
*
* ```
* (unique_id, ckey, null, the current time, the one time token generated)
* the null value will be filled out with the discord id by the integrated discord bot when a user verifies
* ```
*
* Notes:
* * The token is guaranteed to unique during it's validity period
* * The validity period is currently set at 4 hours
* * a token may not be unique outside it's validity window (to reduce conflicts)
*
* Arguments:
* * ckey_for a string representing the ckey this token is for
*
* Returns a string representing the one time token
*/
/datum/controller/subsystem/discord/proc/generate_one_time_token(ckey_for)
var/not_unique = TRUE
var/one_time_token = ""
// While there's a collision in the token, generate a new one (should rarely happen)
while(not_unique)
//Column is varchar 100, so we trim just in case someone does us the dirty later
one_time_token = trim("[pick(common_words)]-[pick(common_words)]-[pick(common_words)]-[pick(common_words)]-[pick(common_words)]-[pick(common_words)]", 100)
not_unique = find_discord_link_by_token(one_time_token, timebound = TRUE)
// Insert into the table, null in the discord id, id and timestamp and valid fields so the db fills them out where needed
var/datum/db_query/query_insert_link_record = SSdbcore.NewQuery(
"INSERT INTO [format_table_name("discord_links")] (ckey, one_time_token) VALUES(:ckey, :token)",
list("ckey" = ckey_for, "token" = one_time_token)
)
if(!query_insert_link_record.Execute())
qdel(query_insert_link_record)
return ""
//Cleanup
qdel(query_insert_link_record)
return one_time_token
/**
* Find discord link entry by the passed in user token
*
* This will look into the discord link table and return the *first* entry that matches the given one time token
*
* Remember, multiple entries can exist, as they are only guaranteed to be unique for their validity period
*
* Arguments:
* * one_time_token the string of words representing the one time token
* * timebound A boolean flag, that specifies if it should only look for entries within the last 4 hours, off by default
*
* Returns a [/datum/discord_link_record]
*/
/datum/controller/subsystem/discord/proc/find_discord_link_by_token(one_time_token, timebound = FALSE)
var/timeboundsql = ""
if(timebound)
timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR"
var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token, valid FROM [format_table_name("discord_links")] WHERE one_time_token = :one_time_token [timeboundsql] GROUP BY ckey, discord_id, one_time_token ORDER BY timestamp DESC LIMIT 1"
var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery(
query,
list("one_time_token" = one_time_token)
)
if(!query_get_discord_link_record.Execute())
qdel(query_get_discord_link_record)
return
if(query_get_discord_link_record.NextRow())
var/result = query_get_discord_link_record.item
. = new /datum/discord_link_record(result[2], result[1], result[4], result[3], result[5])
//Make sure we clean up the query
qdel(query_get_discord_link_record)
/**
* Find discord link entry by the passed in user ckey
*
* This will look into the discord link table and return the *first* entry that matches the given ckey
*
* Remember, multiple entries can exist
*
* Arguments:
* * ckey the users ckey as a string
* * timebound should we search only in the last 4 hours
*
* Returns a [/datum/discord_link_record]
*/
/datum/controller/subsystem/discord/proc/find_discord_link_by_ckey(ckey, timebound = FALSE)
var/timeboundsql = ""
if(timebound)
timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR"
var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token, valid FROM [format_table_name("discord_links")] WHERE ckey = :ckey [timeboundsql] GROUP BY ckey, discord_id, one_time_token ORDER BY timestamp DESC LIMIT 1"
var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery(
query,
list("ckey" = ckey)
)
if(!query_get_discord_link_record.Execute())
qdel(query_get_discord_link_record)
return
if(query_get_discord_link_record.NextRow())
var/result = query_get_discord_link_record.item
. = new /datum/discord_link_record(result[2], result[1], result[4], result[3], result[5])
//Make sure we clean up the query
qdel(query_get_discord_link_record)
/**
* Find discord link entry by the passed in user ckey
*
* This will look into the discord link table and return the *first* entry that matches the given ckey
*
* Remember, multiple entries can exist
*
* Arguments:
* * discord_id The users discord id (string)
* * timebound should we search only in the last 4 hours
*
* Returns a [/datum/discord_link_record]
*/
/datum/controller/subsystem/discord/proc/find_discord_link_by_discord_id(discord_id, timebound = FALSE)
var/timeboundsql = ""
if(timebound)
timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR"
var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token, valid FROM [format_table_name("discord_links")] WHERE discord_id = :discord_id [timeboundsql] GROUP BY ckey, discord_id, one_time_token ORDER BY timestamp DESC LIMIT 1"
var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery(
query,
list("discord_id" = discord_id)
)
if(!query_get_discord_link_record.Execute())
qdel(query_get_discord_link_record)
return
if(query_get_discord_link_record.NextRow())
var/result = query_get_discord_link_record.item
. = new /datum/discord_link_record(result[2], result[1], result[4], result[3], result[5])
//Make sure we clean up the query
qdel(query_get_discord_link_record)
/**
* Delete NULL discord IDs in the database
*
* This will look for rows where discord_id is NULL and delete them.
*
* Hopefully will help fix the errors where people verify but are unable to enter the game
*
*/
/datum/controller/subsystem/discord/proc/delete_nulls()
var/query = "DELETE FROM [format_table_name("discord_links")] WHERE discord_id IS NULL"
var/datum/db_query/query_delete_nulls = SSdbcore.NewQuery(
query
)
if(!query_delete_nulls.Execute())
subsystem_log("There was an error while deleting NULL IDs")
message_admins(span_warning("There was an error while deleting NULL IDs, please delete them manually using the Delete Null Discords verb"))
send2adminchat("Discord Subsystem", "There was an error while deleting NULL IDs, please delete them manually using `!tgs discordnulls`")
qdel(query_delete_nulls)
return FALSE
qdel(query_delete_nulls)
return TRUE
/**
* Check if an account is linked for login
*
* This will look for a valid discord link for the new player's ckey. Defaults to TRUE if the server isn't configured for discord verification
*
* It better be called with ?. in case the subsystem is still Initializing
*
* Arguments:
* * player: the new player to check
*
* Returns TRUE or FALSE
*/
/datum/controller/subsystem/discord/proc/check_login(mob/dead/new_player/player)
. = TRUE
if(!(SSdbcore.IsConnected() && CONFIG_GET(flag/need_discord_to_join) && CONFIG_GET(string/discordbotcommandprefix))) //If not configured/using DB
return TRUE
if(!player.client) //Safety check
return FALSE
if(player.client?.ckey in GLOB.discord_passthrough) //If they have bypass
return TRUE
var/datum/discord_link_record/player_link = find_discord_link_by_ckey(player.client?.ckey)
if(!(player_link && player_link?.discord_id && player_link?.valid))
return FALSE