Swap memos to be Discord pins instead (#1382)

Intent:
If using a Discord bot, make it so that the game is able to pull and process pinned messages in specific discord channels as memos.
This commit is contained in:
skull132
2017-03-19 21:07:29 +02:00
committed by GitHub
parent 2a0353d8f9
commit 9c78f5b4c6
12 changed files with 862 additions and 313 deletions

View File

@@ -187,6 +187,7 @@ var/list/gamemode_cache = list()
var/use_discord_bot = 0
var/discord_bot_host = "localhost"
var/discord_bot_port = 0
var/use_discord_pins = 0
var/python_path = "python" //Path to the python executable. Defaults to "python" on windows and "/usr/bin/env python2" on unix
var/use_lib_nudge = 0 //Use the C library nudge instead of the python nudge.
var/use_overmap = 0
@@ -652,6 +653,9 @@ var/list/gamemode_cache = list()
if("discord_bot_port")
config.discord_bot_port = value
if("use_discord_pins")
config.use_discord_pins = 1
if("python_path")
if(value)
config.python_path = value
@@ -878,6 +882,8 @@ var/list/gamemode_cache = list()
discord_bot.active = 1
if ("robust_debug")
discord_bot.robust_debug = 1
if ("subscriber")
discord_bot.subscriber_role = value
else
log_misc("Unknown setting in discord configuration: '[name]'")

View File

@@ -1,6 +1,13 @@
#define CHAN_ADMIN "channel_admin"
#define CHAN_CCIAA "channel_cciaa"
#define CHAN_ANNOUNCE "channel_announce"
#define CHAN_INVITE "channel_invite"
#define SEND_OK 0
#define SEND_TIMEOUT 1
#define ERROR_PROC 2
#define ERROR_CURL 3
#define ERROR_HTTP 4
var/datum/discord_bot/discord_bot = null
@@ -15,21 +22,34 @@ var/datum/discord_bot/discord_bot = null
discord_bot.update_channels()
if (config.use_discord_pins && server_greeting)
server_greeting.update_pins()
return 1
/datum/discord_bot
var/list/channels = list()
var/list/channels_to_group = list() // Group flag -> list of channel datums map.
var/list/channels = list() // Channel ID -> channel datum map. Will ensure that only one datum per channel ID exists.
var/datum/discord_channel/invite = null // The channel datum where the ingame Join Channel button will link to.
var/active = 0
var/auth_token = ""
var/subscriber_role = ""
var/robust_debug = 0
// Lazy man's rate limiting vars
var/rate_limited_since = 0
var/queue_being_pushed = 0
var/datum/scheduled_task/push_task
var/list/queue = list()
/*
* Proc update_channels
* Used to load channels from the database and construct them with the discord API.
* Wipes all current channels and channel maps.
*
* @return num - Error code. 0 upon success.
*/
/datum/discord_bot/proc/update_channels()
if (!active)
return 1
@@ -38,27 +58,56 @@ var/datum/discord_bot/discord_bot = null
log_debug("BOREALIS: Failed to update channels due to missing database.")
return 2
channels = list()
// Reset the channel lists.
channels_to_group.Cut()
channels.Cut()
var/DBQuery/channel_query = dbcon.NewQuery("SELECT channel_group, channel_id FROM discord_channels")
var/DBQuery/channel_query = dbcon.NewQuery("SELECT channel_group, channel_id, pin_flag, server_id FROM discord_channels")
channel_query.Execute()
var/list/A
while (channel_query.NextRow())
if (isnull(channels[channel_query.item[1]]))
channels[channel_query.item[1]] = list()
// Create the channel map.
if (isnull(channels_to_group[channel_query.item[1]]))
channels_to_group[channel_query.item[1]] = list()
A = channels[channel_query.item[1]]
A += channel_query.item[2]
var/datum/discord_channel/B = channels[channel_query.item[2]]
// We don't have this channel datum yet.
if (isnull(B))
B = new(channel_query.item[2], channel_query.item[4], text2num(channel_query.item[3]))
if (!B)
log_debug("BOREALIS: Bad channel data during update channels. [jointext(channel_query.item, ", ")].")
continue
channels[channel_query.item[2]] = B
if (text2num(channel_query.item[3]))
B.pin_flag |= text2num(channel_query.item[3])
// Add the channel to the required lists.
channels_to_group[channel_query.item[1]] += B
if (!isnull(channels_to_group[CHAN_INVITE]))
invite = channels_to_group[CHAN_INVITE][1]
else if (robust_debug)
log_debug("BOREALIS: No invite channel designated.")
log_debug("BOREALIS: Channels updated successfully.")
return 0
/*
* Proc send_message
* Used to send a message to a specific channel group.
*
* @param text channel_group - The name of the channel group which to target.
* @param text message - The message to send.
*/
/datum/discord_bot/proc/send_message(var/channel_group, var/message)
if (!active || !auth_token)
return
if (!channel_group || !channels.len || isnull(channels[channel_group]))
if (!channel_group || !channels.len || isnull(channels_to_group[channel_group]))
return
if (!message)
@@ -70,18 +119,18 @@ var/datum/discord_bot/discord_bot = null
// Let's run it through the proper JSON encoder, just in case of special characters.
message = json_encode(list("content" = message))
var/list/A = channels[channel_group]
var/list/A = channels_to_group[channel_group]
var/list/sent = list()
for (var/channel in A)
if (send_post_request("https://discordapp.com/api/channels/[channel]/messages", message, "Authorization: Bot [auth_token]", "Content-Type: application/json") == 429)
for (var/B in A)
var/datum/discord_channel/channel = B
if (channel.send_message_to(auth_token, message) == SEND_TIMEOUT)
// Whoopsies, rate limited.
// Set up the queue.
rate_limited_since = world.time
queue.Add(list(message, A - sent))
queue.Add(list(list(message, A - sent)))
// Schedule a push.
spawn (100)
push_queue()
if (!push_task)
push_task = schedule_task_with_source_in(10 SECONDS, src, /datum/discord_bot/proc/push_queue)
// And exit.
return
@@ -89,43 +138,87 @@ var/datum/discord_bot/discord_bot = null
sent += channel
if (robust_debug)
log_debug("BOEALIS: Message sent to [channel_group]. JSON body: '[message]'")
log_debug("BOREALIS: Message sent to [channel_group]. JSON body: '[message]'")
/*
* Proc retreive_pins
* Used to fetch a list of all pins from the designated channels.
*
* @return list - A multilayered list of flags associated with pins. Structure looks like this:
* list("pin_flag" = list(list("author" = author name, "content" = content),
* list("author" = author name, "content" = content)))
*/
/datum/discord_bot/proc/retreive_pins()
if (!active || !auth_token)
return list()
if (!channels.len || isnull(channels_to_group["channel_pins"]))
testing("No group.")
return list()
var/list/output = list()
for (var/A in channels_to_group["channel_pins"])
var/datum/discord_channel/channel = A
if (isnull(output["[channel.pin_flag]"]))
output["[channel.pin_flag]"] = list()
output["[channel.pin_flag]"] += channel.get_pins(auth_token)
return output
/*
* Proc retreive_invite
* Used to retreive the invite to the invite channel.
* One will be created if none exist.
*
* @return text - The invite URL to the designated invite channel.
*/
/datum/discord_bot/proc/retreive_invite()
if (!active || !auth_token)
return ""
if (!invite)
return ""
var/res = invite.get_invite(auth_token)
return isnum(res) ? "" : res
/*
* Proc send_to_admin
* Forwards a message to the admin channels.
*/
/datum/discord_bot/proc/send_to_admins(message)
send_message(CHAN_ADMIN, message)
/*
* Proc send_to_cciaa
* Forwards a message to the CCIAA channels.
*/
/datum/discord_bot/proc/send_to_cciaa(message)
send_message(CHAN_CCIAA, message)
/datum/discord_bot/proc/send_to_announce(message)
/*
* Proc send_to_announce
* Forwards a message to the announcements channels.
*/
/datum/discord_bot/proc/send_to_announce(message, prepend_role = 0)
if (prepend_role && subscriber_role)
message = "<@&[subscriber_role]> " + message
send_message(CHAN_ANNOUNCE, message)
/*
* Proc push_queue
* Handles the queue pushing for the bot. If there is no need to reschedule (all messages get successfully
* pushed), then it deletes push_task and sets it back to null. Otherwise, it simply reschedules it.
*/
/datum/discord_bot/proc/push_queue()
// What facking queue.
if (!queue.len)
if (!queue || !queue.len)
if (robust_debug)
log_debug("BOREALIS: Attempted to push a null length queue.")
if (queue_being_pushed)
queue_being_pushed = 0
return
if (queue_being_pushed)
if (robust_debug)
log_debug("BOREALIS: Attempted to initialize a second queue driver.")
return
if ((world.time - rate_limited_since) < 100)
// Something broke the limit again. Ideally, this wouldn't happen. But sure.
// Use a longer timeout, just in case.
spawn (200)
push_queue()
queue_being_pushed = 0
return
// Async process lock var. No touchy.
queue_being_pushed = 1
// A[1] - message body.
// A[2] - list of channels to send to.
var/message
@@ -134,22 +227,216 @@ var/datum/discord_bot/discord_bot = null
message = A[1]
destinations = A[2]
for (var/channel in destinations)
if (send_post_request("https://discordapp.com/api/channels/[channel]/messages", message, "Authorization: Bot [auth_token]", "Content-Type: application/json") == 429)
// Limited again. Reschedule.
rate_limited_since = world.time
spawn (100)
push_queue()
for (var/B in destinations)
var/datum/discord_channel/channel = B
if (channel.send_message_to(auth_token, message) == SEND_TIMEOUT)
// Tasks nuke themselves after use. So just make a new one! What could possibly go wrong!
push_task = schedule_task_with_source_in(10 SECONDS, src, /datum/discord_bot/proc/push_queue)
queue_being_pushed = 0
return
else
destinations.Remove(channel)
queue.Remove(A)
queue_being_pushed = 0
// A holder class for channels.
/datum/discord_channel
var/id = ""
var/server_id = ""
var/pin_flag = 0
var/invite_url = ""
/*
* Constructor
*
* @param text _id - the discord API id of the channel, as a string.
* @param text _sid - the discord API server id for the channel, as a string.
* @param num _pin - the bitflags of admin permissions which have access to the pins from this channel.
*/
/datum/discord_channel/New(var/_id, var/_sid, var/_pin)
id = _id
server_id = _sid
if (_pin)
pin_flag = _pin
/*
* Proc send_message_to
* Sends a message to the channel.
*
* @param text token - the authorization token to be used for the requests.
* @param text message - the sanitized message content to be sent.
* @return num - a specific return code for the discord_bot to handle.
*/
/datum/discord_channel/proc/send_message_to(var/token, var/message)
if (!token || !message)
return ERROR_PROC
var/res = send_post_request("https://discordapp.com/api/channels/[id]/messages", message, "Authorization: Bot [token]", "Content-Type: application/json")
switch (res)
if (-1)
return ERROR_PROC
if (200)
return SEND_OK
if (429)
return SEND_TIMEOUT
if (0 to 90)
log_debug("BOREALIS: cURL error while forwarding message to Discord API: [res]. Message body: [message].")
return ERROR_CURL
else
log_debug("BOREALIS: HTTP error while forwarding message to Discord API: [res]. Channel: [id]. Message body: [message].")
return ERROR_HTTP
/*
* Proc get_pins
* Retreives a list of pinned messages from the channel.
*
* @param text token - the authorization token to be used for the requests.
* @return mixed - 2d array of content upon success, in format: list(list("author" = text, "content" = text)).
* Num upon failure.
*/
/datum/discord_channel/proc/get_pins(var/token)
var/res = send_get_request("https://discordapp.com/api/channels/[id]/pins", "Authorization: Bot [token]")
// Is a number, so an error code.
if (isnum(res))
switch (res)
if (-1)
return ERROR_PROC
if (0 to 90)
log_debug("BOREALIS: cURL error while fetching pins from the Discord API: [res].")
return ERROR_CURL
if (100 to 600)
log_debug("BOREALIS: HTTP error while fetching pins from the Discord API: [res].")
return ERROR_HTTP
else
log_debug("BOREALIS: Unknown response code while fetching pins from the Discord API: [res].")
return ERROR_PROC
else
var/list/A = res
var/list/pinned_messages = list()
// Begin processing the list structure delivered back to us from send_get_request.
for (var/list/B in A)
var/content = B["content"]
// We have mentions to take care of.
if (!isnull(B["mentions"]))
var/list/mentions = B["mentions"]
for (var/list/C in mentions)
content = replacetextEx(content, "<@[C["id"]]>", C["username"])
// Role mentions are up next.
if (!isnull(B["mention_roles"]))
var/list/mentions = B["mention_roles"]
for (var/C in mentions)
content = replacetextEx(content, "<@&[C]>", "@SomeRole")
pinned_messages += list(list("author" = B["author"]["username"], "content" = content))
return pinned_messages
/*
* Proc get_invite
* Retreives (or creates if none found) an invite URL to the specific channel.
*
* @param text token - the authorization token to be used for the requests.
* @return mixed - text upon success, the link to the invite; num upon failure.
*/
/datum/discord_channel/proc/get_invite(var/token)
if (invite_url)
return invite_url
var/res = send_get_request("https://discordapp.com/api/channels/[id]/invites", "Authorization: Bot [token]")
// Is a number, so an error code.
if (isnum(res))
switch (res)
if (-1)
return ERROR_PROC
if (0 to 90)
log_debug("BOREALIS: cURL error while fetching pins from the Discord API: [res].")
return ERROR_CURL
if (100 to 600)
log_debug("BOREALIS: HTTP error while fetching pins from the Discord API: [res].")
return ERROR_HTTP
else
log_debug("BOREALIS: Unknown response code while fetching pins from the Discord API: [res].")
return ERROR_PROC
else
var/list/A = res
// No length to return data, but a valid 200 return header.
// So we simply have no invites active. Make one!
if (!A || !A.len)
return create_invite(token)
var/best_age = A[1]["max_age"]
var/code = A[1]["code"]
// Find the best invite.
// Why? Because round time expires are lame, I guess.
if (best_age != 0)
for (var/i = 2, i < A.len, i++)
if (A[i]["max_age"] == 0)
code = A[i]["code"]
break
if (best_age < A[i]["max_age"])
best_age = A[i]["max_age"]
code = A[i]["code"]
// Sanity check for debug I guess.
if (!code)
log_debug("BOREALIS: Retreived an empty invite. This should not happen. Response object: [json_encode(A)]")
// Save the URL for later retreival.
invite_url = "https://discord.gg/[code]"
return invite_url
/*
* Proc create_invite
* Creates a permanent invite to a channel and returns it, under the assumption that
* there are no other invites for this channel.
*
* @param text token - the authorization token to be used for the requests.
* @return mixed - String upon success - the URL of the newly generated invite.
* Num upon failure.
*/
/datum/discord_channel/proc/create_invite(var/token)
var/data = list("max_age" = 0, "max_uses" = 0)
var/res = send_post_request("https://discordapp.com/api/channels/[id]/invites", json_encode(data), "Authorization: Bot [token]", "Content-Type: application/json")
if (res == 200)
var/list/get_req = send_get_request("https://discordapp.com/api/channels/[id]/invites", "Authorization: Bot [token]")
if (!istype(get_req) || !get_req.len)
return ERROR_PROC
// The first index should now exist. So we just use that!
invite_url = "https://discord.gg/[get_req[1]["code"]]"
return invite_url
#undef CHAN_ADMIN
#undef CHAN_CCIAA
#undef CHAN_ANNOUNCE
#undef CHAN_INVITE
#undef SEND_OK
#undef SEND_TIMEOUT
#undef ERROR_PROC
#undef ERROR_CURL
#undef ERROR_HTTP

View File

@@ -46,8 +46,9 @@
if (F["motd"])
F["motd"] >> motd
if (F["memo"])
F["memo"] >> memo_list
if (!config.use_discord_pins)
if (F["memo"])
F["memo"] >> memo_list
update_data()
@@ -63,18 +64,32 @@
motd = initial(motd)
motd_hash = ""
if (memo_list.len)
memo = ""
for (var/ckey in memo_list)
var/data = {"<p><b>[ckey]</b> wrote on [memo_list[ckey]["date"]]:<br>
[memo_list[ckey]["content"]]</p>"}
if (!config.use_discord_pins)
// The initialization of memos in case use_discord_pins == 1 is done in discord_bot.dm
// Primary reason is to avoid null references when the bot isn't created yet.
if (memo_list.len)
memo = ""
for (var/ckey in memo_list)
var/data = {"<p><b>[ckey]</b> wrote on [memo_list[ckey]["date"]]:<br>
[memo_list[ckey]["content"]]</p>"}
memo += data
memo += data
memo_hash = md5(memo)
else
memo = initial(memo)
memo_hash = ""
memo_hash = md5(memo)
else
memo = initial(memo)
memo_hash = ""
/datum/server_greeting/proc/update_pins()
var/list/temp_list = discord_bot.retreive_pins()
// A is a number in a string form
// temp_list[A] is a list of lists.
for (var/A in temp_list)
var/list/memos = temp_list[A]
var/flag = text2num(A)
memo_list += new /datum/memo_datum(memos, flag)
/*
* Helper to update the MoTD or memo contents.
@@ -94,9 +109,15 @@
motd = new_value
if ("memo_write")
if (config.use_discord_pins)
return 0
memo_list[new_value[1]] = list("date" = time2text(world.realtime, "DD-MMM-YYYY"), "content" = new_value[2])
if ("memo_delete")
if (config.use_discord_pins)
return 0
if (memo_list[new_value])
memo_list -= new_value
else
@@ -132,7 +153,7 @@
if (motd_hash && user.prefs.motd_hash != motd_hash)
outdated_info |= OUTDATED_MOTD
if (user.holder && memo_hash && user.prefs.memo_hash != memo_hash)
if (user.holder && user.prefs.memo_hash != get_memo_hash(user))
outdated_info |= OUTDATED_MEMO
if (user.prefs.notifications.len)
@@ -185,13 +206,13 @@
else
if (outdated_info & OUTDATED_MEMO)
data["update"] = 1
data["changeHash"] = memo_hash
data["changeHash"] = get_memo_hash(user)
else
data["update"] = 0
data["changeHash"] = null
data["div"] = "#memo"
data["content"] = memo
data["content"] = get_memo_content(user)
user << output(JS_SANITIZE(data), "greeting.browser:AddContent")
if (outdated_info & OUTDATED_MOTD)
@@ -216,6 +237,81 @@
if ("request_data")
send_to_javascript(C)
/*
* Gets the appropriate memo hash for the memo system in use.
* Args:
* - var/C client
* Returns:
* - string
*/
/datum/server_greeting/proc/get_memo_hash(var/client/C)
if (!C || !C.holder)
return ""
if (!config.use_discord_pins)
return memo_hash
var/joint_checksum = ""
for (var/A in memo_list)
var/datum/memo_datum/memo = A
if (C.holder.rights & memo.flag)
joint_checksum += memo.hash
return md5(joint_checksum)
/*
* Gets the appropriate memo content for the memo system in use.
* Args:
* - var/C client
* Returns:
* - string if old memo system is used (config.use_discord_pins = 0)
* - list of strings if new memo system is used
*/
/datum/server_greeting/proc/get_memo_content(var/client/C)
if (!C || !C.holder)
return ""
if (!config.use_discord_pins)
return memo
var/list/content = list()
for (var/A in memo_list)
var/datum/memo_datum/memo = A
if (C.holder.rights & memo.flag)
content += memo.contents
return content
/datum/memo_datum
var/contents
var/hash
var/flag
/datum/memo_datum/New(var/list/input = list(), var/_flag)
flag = _flag
// Yes. This is an unfortunately acceptable way of doing it.
// Why? Because you cannot use numbers as indexes in an assoc list without fucking DM.
var/static/list/flags_to_divs = list("[R_ADMIN]" = "danger",
"[R_MOD]" = "warning",
"[(R_MOD|R_ADMIN)]" = "warning",
"[R_CCIAA]" = "info",
"[R_DEV]" = "info")
if (input.len)
contents = "<div class='alert alert-[flags_to_divs["[flag]"]]'>"
for (var/i = 1, i <= input.len, i++)
contents += "<b>[input[i]["author"]]</b> wrote:<br>[nl2br(input[i]["content"])]"
if (i < input.len)
contents += "<hr></hr>"
contents += "</div>"
else
contents = ""
hash = md5(contents)
#undef OUTDATED_NOTE
#undef OUTDATED_MEMO
#undef OUTDATED_MOTD

View File

@@ -283,7 +283,7 @@ var/global/list/additional_antag_types = list()
/datum/game_mode/proc/declare_completion()
var/is_antag_mode = (antag_templates && antag_templates.len)
var/discord_text = "@subscribers A round of **[name]** has ended! \[Game ID: [game_id]\]\n\n"
var/discord_text = "A round of **[name]** has ended! \[Game ID: [game_id]\]\n\n"
check_victory()
if(is_antag_mode)
sleep(10)
@@ -298,7 +298,7 @@ var/global/list/additional_antag_types = list()
sleep(10)
print_ownerless_uplinks()
discord_bot.send_to_announce(discord_text)
discord_bot.send_to_announce(discord_text, 1)
discord_text = ""
var/clients = 0

View File

@@ -58,7 +58,7 @@
var/result = call("ByondPOST.dll", "send_post_request")(arglist(args))
if (!result)
log_debug("ByondPOST: No result returned from external library.")
log_debug("ByondPOST POST: No result returned from external library.")
return -1
var/list/A = params2list(result)
@@ -67,14 +67,61 @@
// Log the proc error. It should be reviewed by coders ASAP.
switch (A["proc"])
if ("1")
log_debug("ByondPOST: Proc error: Too few arguments sent to function.")
log_debug("ByondPOST POST: Proc error: Too few arguments sent to function.")
if ("2")
log_debug("ByondPOST: Proc error: Unable to initialize curl object.")
log_debug("ByondPOST POST: Proc error: Unable to initialize curl object.")
else
log_debug("ByondPOST: Proc error: Unknown error.")
log_debug("ByondPOST POST: Proc error: Unknown error.")
return -1
// Curl oriented errors should leave the HTTP response code at 0, as no request was executed.
// All HTTP oriented errors will definately return a response code other than 0, so prioritize that.
// Fallback is a curl error code (0 - 92).
return text2num(A["http"]) != 0 ? text2num(A["http"]) : text2num(A["curl"])
/*
* A generic proc for sending a header equipped get requests with the aforementioned .DLL files.
* If you're using this without sending custom headers, please stop. Use world.Export() instead.
* Expected arg structure:
* 1st arg - the url
* 2nd - nth arg - individual headers and their values in format: "headername: value"
*
* @return mixed - Returns list if request was successful, integer (specific cURL or HTTP error) if failed.
* -1 indicates proc or library failure.
* 0 - 92 are curl errors, and are usually accompanied by a HTTP response code of 0 (request was never made).
* 100 - 6xx are HTTP response codes. Curl error code should be 0 in this case, but, in case that it is not,
* the HTTP response code is always returned as long as it is not 0.
*
*/
/proc/send_get_request()
if (args.len < 2)
return -1
var/result = call("ByondPOST.dll", "send_get_request")(arglist(args))
if (!result)
log_debug("ByondPOST GET: No result returned from external library.")
return -1
var/list/A
// Try to evaluate it as JSON data (successful request)
try
A = json_decode(result)
return A
catch()
// Nope, we failed. do regular error parsing instead.
A = params2list(result)
if (!isnull(A["proc"]))
switch (A["proc"])
if ("1")
log_debug("ByondPOST GET: Proc error: Too few arguments sent to function.")
if ("2")
log_debug("ByondPOST GET: Proc error: Unable to initialize curl object.")
else
log_debug("ByondPOST GET: Proc error: Unknown error.")
return -1
return text2num(A["http"]) != 0 ? text2num(A["http"]) : text2num(A["curl"])