diff --git a/baystation12.dme b/baystation12.dme
index 1ad954194da..da0e466ded6 100644
--- a/baystation12.dme
+++ b/baystation12.dme
@@ -20,6 +20,7 @@
#include "code\__datastructures\stack.dm"
#include "code\__defines\_compile_options.dm"
#include "code\__defines\_planes+layers.dm"
+#include "code\__defines\_protect.dm"
#include "code\__defines\_tick.dm"
#include "code\__defines\admin.dm"
#include "code\__defines\antagonists.dm"
@@ -74,6 +75,8 @@
#include "code\__defines\subsystems.dm"
#include "code\__defines\targeting.dm"
#include "code\__defines\temperature.dm"
+#include "code\__defines\tgs.config.dm"
+#include "code\__defines\tgs.dm"
#include "code\__defines\topic.dm"
#include "code\__defines\turfs.dm"
#include "code\__defines\webhooks.dm"
@@ -253,6 +256,7 @@
#include "code\datums\sound_player.dm"
#include "code\datums\suit_sensor_jammer_method.dm"
#include "code\datums\sun.dm"
+#include "code\datums\tgs_event_handler.dm"
#include "code\datums\weakref.dm"
#include "code\datums\ai\ai.dm"
#include "code\datums\ai\ai_holo.dm"
@@ -2991,6 +2995,7 @@
#include "code\modules\tables\rack.dm"
#include "code\modules\tables\tables.dm"
#include "code\modules\tables\update_triggers.dm"
+#include "code\modules\tgs\includes.dm"
#include "code\modules\turbolift\_turbolift.dm"
#include "code\modules\turbolift\turbolift.dm"
#include "code\modules\turbolift\turbolift_areas.dm"
diff --git a/code/__defines/_protect.dm b/code/__defines/_protect.dm
new file mode 100644
index 00000000000..a4642800779
--- /dev/null
+++ b/code/__defines/_protect.dm
@@ -0,0 +1,7 @@
+#define PROTECT_PATH(Path)\
+##Path/IsProtected(){\
+ return TRUE;\
+}
+
+/datum/proc/IsProtected()
+ return FALSE
diff --git a/code/__defines/tgs.config.dm b/code/__defines/tgs.config.dm
new file mode 100644
index 00000000000..49ccbd887de
--- /dev/null
+++ b/code/__defines/tgs.config.dm
@@ -0,0 +1,11 @@
+#define TGS_EXTERNAL_CONFIGURATION
+#define TGS_DEFINE_AND_SET_GLOBAL(Name, Value) GLOBAL_VAR_INIT(##Name, ##Value); GLOBAL_PROTECT(##Name)
+#define TGS_READ_GLOBAL(Name) GLOB.##Name
+#define TGS_WRITE_GLOBAL(Name, Value) GLOB.##Name = ##Value
+#define TGS_PROTECT_DATUM(Path) PROTECT_PATH(##Path)
+#define TGS_WORLD_ANNOUNCE(message) to_chat(world, "[html_encode(##message)]
")
+#define TGS_NOTIFY_ADMINS(event) message_admins(##event)
+#define TGS_INFO_LOG(message) log_world("TGS Info: [##message]")
+#define TGS_WARNING_LOG(message) log_world("TGS Warn: [##message]")
+#define TGS_ERROR_LOG(message) log_world("TGS Error: [##message]")
+#define TGS_CLIENT_COUNT length(GLOB.clients)
diff --git a/code/__defines/tgs.dm b/code/__defines/tgs.dm
new file mode 100644
index 00000000000..7ddae3b0bb2
--- /dev/null
+++ b/code/__defines/tgs.dm
@@ -0,0 +1,378 @@
+// tgstation-server DMAPI
+
+#define TGS_DMAPI_VERSION "6.0.4"
+
+// All functions and datums outside this document are subject to change with any version and should not be relied on.
+
+// CONFIGURATION
+
+/// Create this define if you want to do TGS configuration outside of this file.
+#ifndef TGS_EXTERNAL_CONFIGURATION
+
+// Comment this out once you've filled in the below.
+#error TGS API unconfigured
+
+// Uncomment this if you wish to allow the game to interact with TGS 3.
+// This will raise the minimum required security level of your game to TGS_SECURITY_TRUSTED due to it utilizing call()()
+//#define TGS_V3_API
+
+// Required interfaces (fill in with your codebase equivalent):
+
+/// Create a global variable named `Name` and set it to `Value`.
+#define TGS_DEFINE_AND_SET_GLOBAL(Name, Value)
+
+/// Read the value in the global variable `Name`.
+#define TGS_READ_GLOBAL(Name)
+
+/// Set the value in the global variable `Name` to `Value`.
+#define TGS_WRITE_GLOBAL(Name, Value)
+
+/// Disallow ANYONE from reflecting a given `path`, security measure to prevent in-game use of DD -> TGS capabilities.
+#define TGS_PROTECT_DATUM(Path)
+
+/// Display an announcement `message` from the server to all players.
+#define TGS_WORLD_ANNOUNCE(message)
+
+/// Notify current in-game administrators of a string `event`.
+#define TGS_NOTIFY_ADMINS(event)
+
+/// Write an info `message` to a server log.
+#define TGS_INFO_LOG(message)
+
+/// Write an warning `message` to a server log.
+#define TGS_WARNING_LOG(message)
+
+/// Write an error `message` to a server log.
+#define TGS_ERROR_LOG(message)
+
+/// Get the number of connected /clients.
+#define TGS_CLIENT_COUNT
+
+#endif
+
+// EVENT CODES
+
+/// Before a reboot mode change, extras parameters are the current and new reboot mode enums
+#define TGS_EVENT_REBOOT_MODE_CHANGE -1
+/// Before a port change is about to happen, extra parameters is new port
+#define TGS_EVENT_PORT_SWAP -2
+/// Before the instance is renamed, extra parameter is the new name
+#define TGS_EVENT_INSTANCE_RENAMED -3
+/// After the watchdog reattaches to DD, extra parameter is the new [/datum/tgs_version] of the server
+#define TGS_EVENT_WATCHDOG_REATTACH -4
+
+/// When the repository is reset to its origin reference. Parameters: Reference name, Commit SHA
+#define TGS_EVENT_REPO_RESET_ORIGIN 0
+/// When the repository performs a checkout. Parameters: Checkout git object
+#define TGS_EVENT_REPO_CHECKOUT 1
+/// When the repository performs a fetch operation. No parameters
+#define TGS_EVENT_REPO_FETCH 2
+/// When the repository test merges. Parameters: PR Number, PR Sha, (Nullable) Comment made by TGS user
+#define TGS_EVENT_REPO_MERGE_PULL_REQUEST 3
+/// Before the repository makes a sychronize operation. Parameters: Absolute repostiory path
+#define TGS_EVENT_REPO_PRE_SYNCHRONIZE 4
+/// Before a BYOND install operation begins. Parameters: [/datum/tgs_version] of the installing BYOND
+#define TGS_EVENT_BYOND_INSTALL_START 5
+/// When a BYOND install operation fails. Parameters: Error message
+#define TGS_EVENT_BYOND_INSTALL_FAIL 6
+/// When the active BYOND version changes. Parameters: (Nullable) [/datum/tgs_version] of the current BYOND, [/datum/tgs_version] of the new BYOND
+#define TGS_EVENT_BYOND_ACTIVE_VERSION_CHANGE 7
+/// When the compiler starts running. Parameters: Game directory path, origin commit SHA
+#define TGS_EVENT_COMPILE_START 8
+/// When a compile is cancelled. No parameters
+#define TGS_EVENT_COMPILE_CANCELLED 9
+/// When a compile fails. Parameters: Game directory path, [TRUE]/[FALSE] based on if the cause for failure was DMAPI validation
+#define TGS_EVENT_COMPILE_FAILURE 10
+/// When a compile operation completes. Note, this event fires before the new .dmb is loaded into the watchdog. Consider using the [TGS_EVENT_DEPLOYMENT_COMPLETE] instead. Parameters: Game directory path
+#define TGS_EVENT_COMPILE_COMPLETE 11
+/// When an automatic update for the current instance begins. No parameters
+#define TGS_EVENT_INSTANCE_AUTO_UPDATE_START 12
+/// When the repository encounters a merge conflict: Parameters: Base SHA, target SHA, base reference, target reference
+#define TGS_EVENT_REPO_MERGE_CONFLICT 13
+/// When a deployment completes. No Parameters
+#define TGS_EVENT_DEPLOYMENT_COMPLETE 14
+/// Before the watchdog shuts down. Not sent for graceful shutdowns. No parameters.
+#define TGS_EVENT_WATCHDOG_SHUTDOWN 15
+/// Before the watchdog detaches for a TGS update/restart. No parameters.
+#define TGS_EVENT_WATCHDOG_DETACH 16
+// We don't actually implement these 4 events as the DMAPI can never receive them.
+// #define TGS_EVENT_WATCHDOG_LAUNCH 17
+// #define TGS_EVENT_WATCHDOG_CRASH 18
+// #define TGS_EVENT_WORLD_END_PROCESS 19
+// #define TGS_EVENT_WORLD_REBOOT 20
+/// Watchdog event when TgsInitializationComplete() is called. No parameters.
+#define TGS_EVENT_WORLD_PRIME 21
+// DMAPI also doesnt implement this
+// #define TGS_EVENT_DREAM_DAEMON_LAUNCH 22
+
+// OTHER ENUMS
+
+/// The server will reboot normally.
+#define TGS_REBOOT_MODE_NORMAL 0
+/// The server will stop running on reboot.
+#define TGS_REBOOT_MODE_SHUTDOWN 1
+/// The watchdog will restart on reboot.
+#define TGS_REBOOT_MODE_RESTART 2
+
+/// DreamDaemon Trusted security level.
+#define TGS_SECURITY_TRUSTED 0
+/// DreamDaemon Safe security level.
+#define TGS_SECURITY_SAFE 1
+/// DreamDaemon Ultrasafe security level.
+#define TGS_SECURITY_ULTRASAFE 2
+
+//REQUIRED HOOKS
+
+/**
+ * Call this somewhere in [/world/proc/New] that is always run. This function may sleep!
+ *
+ * * event_handler - Optional user defined [/datum/tgs_event_handler].
+ * * minimum_required_security_level: The minimum required security level to run the game in which the DMAPI is integrated. Can be one of [TGS_SECURITY_ULTRASAFE], [TGS_SECURITY_SAFE], or [TGS_SECURITY_TRUSTED].
+ */
+/world/proc/TgsNew(datum/tgs_event_handler/event_handler, minimum_required_security_level = TGS_SECURITY_ULTRASAFE)
+ return
+
+/**
+ * Call this when your initializations are complete and your game is ready to play before any player interactions happen.
+ *
+ * This may use [/world/var/sleep_offline] to make this happen so ensure no changes are made to it while this call is running.
+ * Afterwards, consider explicitly setting it to what you want to avoid this BYOND bug: http://www.byond.com/forum/post/2575184
+ * This function should not be called before ..() in [/world/proc/New].
+ */
+/world/proc/TgsInitializationComplete()
+ return
+
+/// Put this at the start of [/world/proc/Topic].
+#define TGS_TOPIC var/tgs_topic_return = TgsTopic(args[1]); if(tgs_topic_return) return tgs_topic_return
+
+/**
+ * Call this as late as possible in [world/proc/Reboot].
+ */
+/world/proc/TgsReboot()
+ return
+
+// DATUM DEFINITIONS
+// All datums defined here should be considered read-only
+
+/// Represents git revision information.
+/datum/tgs_revision_information
+ /// Full SHA of the commit.
+ var/commit
+ /// ISO 8601 timestamp of when the commit was created
+ var/timestamp
+ /// Full sha of last known remote commit. This may be null if the TGS repository is not currently tracking a remote branch.
+ var/origin_commit
+
+/// Represents a version.
+/datum/tgs_version
+ /// The suite/major version number
+ var/suite
+
+ // This group of variables can be null to represent a wild card
+ /// The minor version number. null for wildcards
+ var/minor
+ /// The patch version number. null for wildcards
+ var/patch
+
+ /// Legacy version number. Generally null
+ var/deprecated_patch
+
+ /// Unparsed string value
+ var/raw_parameter
+ /// String value minus prefix
+ var/deprefixed_parameter
+
+/**
+ * Returns [TRUE]/[FALSE] based on if the [/datum/tgs_version] contains wildcards.
+ */
+/datum/tgs_version/proc/Wildcard()
+ return
+
+/**
+ * Returns [TRUE]/[FALSE] based on if the [/datum/tgs_version] equals some other version.
+ *
+ * other_version - The [/datum/tgs_version] to compare against.
+ */
+/datum/tgs_version/proc/Equals(datum/tgs_version/other_version)
+ return
+
+/// Represents a merge of a GitHub pull request.
+/datum/tgs_revision_information/test_merge
+ /// The test merge number.
+ var/number
+ /// The test merge source's title when it was merged.
+ var/title
+ /// The test merge source's body when it was merged.
+ var/body
+ /// The Username of the test merge source's author.
+ var/author
+ /// An http URL to the test merge source.
+ var/url
+ /// The SHA of the test merge when that was merged.
+ var/head_commit
+ /// Optional comment left by the TGS user who initiated the merge.
+ var/comment
+
+/// Represents a connected chat channel.
+/datum/tgs_chat_channel
+ /// TGS internal channel ID.
+ var/id
+ /// User friendly name of the channel.
+ var/friendly_name
+ /// Name of the chat connection. This is the IRC server address or the Discord guild.
+ var/connection_name
+ /// [TRUE]/[FALSE] based on if the server operator has marked this channel for game admins only.
+ var/is_admin_channel
+ /// [TRUE]/[FALSE] if the channel is a private message channel for a [/datum/tgs_chat_user].
+ var/is_private_channel
+ /// Tag string associated with the channel in TGS
+ var/custom_tag
+
+// Represents a chat user
+/datum/tgs_chat_user
+ /// TGS internal user ID.
+ var/id
+ // The user's display name.
+ var/friendly_name
+ // The string to use to ping this user in a message.
+ var/mention
+ /// The [/datum/tgs_chat_channel] the user was from
+ var/datum/tgs_chat_channel/channel
+
+/**
+ * User definable callback for handling TGS events.
+ *
+ * event_code - One of the TGS_EVENT_ defines. Extra parameters will be documented in each
+ */
+/datum/tgs_event_handler/proc/HandleEvent(event_code, ...)
+ set waitfor = FALSE
+ return
+
+/// User definable chat command
+/datum/tgs_chat_command
+ /// The string to trigger this command on a chat bot. e.g `@bot name ...` or `!tgs name ...`
+ var/name = ""
+ /// The help text displayed for this command
+ var/help_text = ""
+ /// If this command should be available to game administrators only
+ var/admin_only = FALSE
+
+/**
+ * Process command activation. Should return a string to respond to the issuer with.
+ *
+ * sender - The [/datum/tgs_chat_user] who issued the command.
+ * params - The trimmed string following the command `/datum/tgs_chat_command/var/name].
+ */
+/datum/tgs_chat_command/proc/Run(datum/tgs_chat_user/sender, params)
+ CRASH("[type] has no implementation for Run()")
+
+// API FUNCTIONS
+
+/// Returns the maximum supported [/datum/tgs_version] of the DMAPI.
+/world/proc/TgsMaximumApiVersion()
+ return
+
+/// Returns the minimum supported [/datum/tgs_version] of the DMAPI.
+/world/proc/TgsMinimumApiVersion()
+ return
+
+/**
+ * Returns [TRUE] if DreamDaemon was launched under TGS, the API matches, and was properly initialized. [FALSE] will be returned otherwise.
+ */
+/world/proc/TgsAvailable()
+ return
+
+// No function below this succeeds if it TgsAvailable() returns FALSE or if TgsNew() has yet to be called.
+
+/**
+ * Forces a hard reboot of DreamDaemon by ending the process.
+ *
+ * Unlike del(world) clients will try to reconnect.
+ * If TGS has not requested a [TGS_REBOOT_MODE_SHUTDOWN] DreamDaemon will be launched again
+ */
+/world/proc/TgsEndProcess()
+ return
+
+/**
+ * Send a message to connected chats.
+ *
+ * message - The string to send.
+ * admin_only: If [TRUE], message will be sent to admin connected chats. Vice-versa applies.
+ */
+/world/proc/TgsTargetedChatBroadcast(message, admin_only = FALSE)
+ return
+
+/**
+ * Send a private message to a specific user.
+ *
+ * message - The string to send.
+ * user: The [/datum/tgs_chat_user] to PM.
+ */
+/world/proc/TgsChatPrivateMessage(message, datum/tgs_chat_user/user)
+ return
+
+// The following functions will sleep if a call to TgsNew() is sleeping
+
+/**
+ * Send a message to connected chats that are flagged as game-related in TGS.
+ *
+ * message - The string to send.
+ * channels - Optional list of [/datum/tgs_chat_channel]s to restrict the message to.
+ */
+/world/proc/TgsChatBroadcast(message, list/channels = null)
+ return
+
+/// Returns the current [/datum/tgs_version] of TGS if it is running the server, null otherwise.
+/world/proc/TgsVersion()
+ return
+
+/// Returns the current [/datum/tgs_version] of the DMAPI being used if it was activated, null otherwise.
+/world/proc/TgsApiVersion()
+ return
+
+/// Returns the name of the TGS instance running the game if TGS is present, null otherwise.
+/world/proc/TgsInstanceName()
+ return
+
+/// Return the current [/datum/tgs_revision_information] of the running server if TGS is present, null otherwise.
+/world/proc/TgsRevision()
+ return
+
+/// Returns the current BYOND security level as a TGS_SECURITY_ define if TGS is present, null otherwise.
+/world/proc/TgsSecurityLevel()
+ return
+
+/// Returns a list of active [/datum/tgs_revision_information/test_merge]s if TGS is present, null otherwise.
+/world/proc/TgsTestMerges()
+ return
+
+/// Returns a list of connected [/datum/tgs_chat_channel]s if TGS is present, null otherwise.
+/world/proc/TgsChatChannelInfo()
+ return
+
+/*
+The MIT License
+
+Copyright (c) 2017 Jordan Brown
+
+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.
+*/
diff --git a/code/controllers/master.dm b/code/controllers/master.dm
index 9f537b0fa8b..c3ee175d705 100644
--- a/code/controllers/master.dm
+++ b/code/controllers/master.dm
@@ -162,7 +162,7 @@ GLOBAL_REAL(Master, /datum/controller/master) = new
// Please don't stuff random bullshit here,
// Make a subsystem, give it the SS_NO_FIRE flag, and do your work in it's Initialize()
-/datum/controller/master/Initialize(delay, init_sss)
+/datum/controller/master/Initialize(delay, init_sss, tgs_prime)
set waitfor = 0
if(delay)
@@ -209,6 +209,9 @@ GLOBAL_REAL(Master, /datum/controller/master) = new
world.fps = config.fps
var/initialized_tod = REALTIMEOFDAY
+ if(tgs_prime)
+ world.TgsInitializationComplete()
+
initializations_finished_with_no_players_logged_in = initialized_tod < REALTIMEOFDAY - 10
// Loop.
Master.StartProcessing(0)
diff --git a/code/datums/tgs_event_handler.dm b/code/datums/tgs_event_handler.dm
new file mode 100644
index 00000000000..e167b523fe9
--- /dev/null
+++ b/code/datums/tgs_event_handler.dm
@@ -0,0 +1,41 @@
+/datum/tgs_event_handler/impl
+ var/datum/timedevent/reattach_timer
+
+/datum/tgs_event_handler/impl/HandleEvent(event_code, ...)
+ switch(event_code)
+ if(TGS_EVENT_REBOOT_MODE_CHANGE)
+ var/list/reboot_mode_lookup = list ("[TGS_REBOOT_MODE_NORMAL]" = "be normal", "[TGS_REBOOT_MODE_SHUTDOWN]" = "shutdown the server", "[TGS_REBOOT_MODE_RESTART]" = "hard restart the server")
+ var/old_reboot_mode = args[2]
+ var/new_reboot_mode = args[3]
+ message_admins("TGS: Reboot will no longer [reboot_mode_lookup["[old_reboot_mode]"]], it will instead [reboot_mode_lookup["[new_reboot_mode]"]]")
+ if(TGS_EVENT_PORT_SWAP)
+ message_admins("TGS: Changing port from [world.port] to [args[2]]")
+ if(TGS_EVENT_INSTANCE_RENAMED)
+ message_admins("TGS: Instance renamed to from [world.TgsInstanceName()] to [args[2]]")
+ if(TGS_EVENT_COMPILE_START)
+ message_admins("TGS: Deployment started, new game version incoming...")
+ if(TGS_EVENT_COMPILE_CANCELLED)
+ message_admins("TGS: Deployment cancelled!")
+ if(TGS_EVENT_COMPILE_FAILURE)
+ message_admins("TGS: Deployment failed!")
+ if(TGS_EVENT_DEPLOYMENT_COMPLETE)
+ message_admins("TGS: Deployment complete!")
+ to_chat(world, "Server updated, changes will be applied on the next round...")
+ if(TGS_EVENT_WATCHDOG_DETACH)
+ message_admins("TGS restarting...")
+ reattach_timer = addtimer(CALLBACK(src, .proc/LateOnReattach), 1 MINUTES, TIMER_STOPPABLE)
+ if(TGS_EVENT_WATCHDOG_REATTACH)
+ var/datum/tgs_version/old_version = world.TgsVersion()
+ var/datum/tgs_version/new_version = args[2]
+ if(!old_version.Equals(new_version))
+ to_chat(world, "TGS updated to v[new_version.deprefixed_parameter]")
+ else
+ message_admins("TGS: Back online")
+ if(reattach_timer)
+ deltimer(reattach_timer)
+ reattach_timer = null
+ if(TGS_EVENT_WATCHDOG_SHUTDOWN)
+ to_chat_immediate(world, "Server is shutting down!")
+
+/datum/tgs_event_handler/impl/proc/LateOnReattach()
+ message_admins("Warning: TGS hasn't notified us of it coming back for a full minute! Is there a problem?")
diff --git a/code/game/world.dm b/code/game/world.dm
index 71db895c530..22f26976901 100644
--- a/code/game/world.dm
+++ b/code/game/world.dm
@@ -79,6 +79,8 @@ GLOBAL_VAR(href_logfile)
diary = file("data/logs/[date_string].log")
to_file(diary, "[log_end]\n[log_end]\nStarting up. (ID: [game_id]) [time2text(world.timeofday, "hh:mm.ss")][log_end]\n---------------------[log_end]")
+ TgsNew(new /datum/tgs_event_handler/impl, TGS_SECURITY_TRUSTED)
+
if(config && config.server_name != null && config.server_suffix && world.port > 0)
config.server_name += " #[(world.port % 1000) / 100]"
@@ -104,7 +106,7 @@ GLOBAL_VAR(href_logfile)
log_unit_test("Unit Tests Enabled. This will destroy the world when testing is complete.")
load_unit_test_changes()
#endif
- Master.Initialize(10, FALSE)
+ Master.Initialize(10, FALSE, TRUE)
#undef RECOMMENDED_VERSION
@@ -162,7 +164,7 @@ GLOBAL_VAR_INIT(world_topic_last, world.timeofday)
s["players"] = 0
s["stationtime"] = stationtime2text()
s["roundduration"] = roundduration2text()
- s["map"] = replacetext(GLOB.using_map.full_name, "\improper", "") //Done to remove the non-UTF-8 text macros
+ s["map"] = replacetext(GLOB.using_map.full_name, "\improper", "") //Done to remove the non-UTF-8 text macros
var/active = 0
var/list/players = list()
@@ -465,6 +467,8 @@ GLOBAL_VAR_INIT(world_topic_last, world.timeofday)
to_world("World reboot waiting for external scripts. Please be patient.")
return
+ world.TgsReboot()
+
..(reason)
/world/Del()
diff --git a/code/modules/admin/callproc/callproc.dm b/code/modules/admin/callproc/callproc.dm
index e9c40a74194..58fa57f3b70 100644
--- a/code/modules/admin/callproc/callproc.dm
+++ b/code/modules/admin/callproc/callproc.dm
@@ -52,6 +52,8 @@
// this needs checking again here because VV's 'Call Proc' option directly calls this proc with the target datum
if(!check_rights(R_DEBUG)) return
if(config.debugparanoid && !check_rights(R_ADMIN)) return
+ if(target.IsProtected())
+ to_chat(usr, "It is forbidden to call procs on this object.")
if(!holder.callproc)
holder.callproc = new(src)
diff --git a/code/modules/admin/holder2.dm b/code/modules/admin/holder2.dm
index 8706e4dbb9b..22512804c33 100644
--- a/code/modules/admin/holder2.dm
+++ b/code/modules/admin/holder2.dm
@@ -17,6 +17,9 @@ var/list/admin_datums = list()
var/datum/feed_channel/admincaster_feed_channel = new /datum/feed_channel
var/admincaster_signature //What you'll sign the newsfeeds as
+/datum/admins/IsProtected()
+ return TRUE
+
/datum/admins/proc/marked_datum()
if(marked_datum_weak)
return marked_datum_weak.resolve()
@@ -152,4 +155,4 @@ NOTE: It checks usr by default. Supply the "user" argument if you wish to check
#undef STEALTH_OFF
#undef STEALTH_MANUAL
-#undef STEALTH_AUTO
\ No newline at end of file
+#undef STEALTH_AUTO
diff --git a/code/modules/admin/verbs/massmodvar.dm b/code/modules/admin/verbs/massmodvar.dm
index aa055ead6f8..76d32b036ca 100644
--- a/code/modules/admin/verbs/massmodvar.dm
+++ b/code/modules/admin/verbs/massmodvar.dm
@@ -28,10 +28,8 @@
var/list/locked = list("vars", "key", "ckey", "client")
- for(var/p in forbidden_varedit_object_types())
- if( istype(O,p) )
- to_chat(usr, "It is forbidden to edit this object's variables.")
- return
+ if(O.IsProtected())
+ to_chat(usr, "It is forbidden to edit this object's variables.")
var/list/names = list()
for (var/V in O.vars)
diff --git a/code/modules/admin/verbs/modifyvariables.dm b/code/modules/admin/verbs/modifyvariables.dm
index e2fe2d14949..fac4ab91ddf 100644
--- a/code/modules/admin/verbs/modifyvariables.dm
+++ b/code/modules/admin/verbs/modifyvariables.dm
@@ -325,10 +325,9 @@
/client/proc/modify_variables(var/atom/O, var/param_var_name = null, var/autodetect_class = 0)
if(!check_rights(R_VAREDIT)) return
- for(var/p in forbidden_varedit_object_types())
- if( istype(O,p) )
- to_chat(usr, "It is forbidden to edit this object's variables.")
- return
+ if(O.IsProtected())
+ to_chat(usr, "It is forbidden to edit this object's variables.")
+ return
var/class
var/variable
diff --git a/code/modules/admin/view_variables/helpers.dm b/code/modules/admin/view_variables/helpers.dm
index 3b57e01049b..9084b7d8092 100644
--- a/code/modules/admin/view_variables/helpers.dm
+++ b/code/modules/admin/view_variables/helpers.dm
@@ -195,7 +195,9 @@
return FALSE
return TRUE
+/* Terra: Unused
/proc/forbidden_varedit_object_types()
return list(
/datum/admins //Admins editing their own admin-power object? Yup, sounds like a good idea.
)
+*/
diff --git a/code/modules/tgs/LICENSE b/code/modules/tgs/LICENSE
new file mode 100644
index 00000000000..221f9e1deb2
--- /dev/null
+++ b/code/modules/tgs/LICENSE
@@ -0,0 +1,24 @@
+The MIT License
+
+Copyright (c) 2017 Jordan Brown
+
+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.
diff --git a/code/modules/tgs/README.md b/code/modules/tgs/README.md
new file mode 100644
index 00000000000..445cee41f57
--- /dev/null
+++ b/code/modules/tgs/README.md
@@ -0,0 +1,13 @@
+# DMAPI Internals
+
+This folder should be placed on it's own inside a codebase that wishes to use the TGS DMAPI. Warranty void if modified.
+
+- [includes.dm](./includes.dm) is the file that should be included by DM code, it handles including the rest.
+- The [core](./core) folder includes all code not directly part of any API version.
+- The other versioned folders contain code for the different DMAPI versions.
+ - [v3210](./v3210) contains the final TGS3 API.
+ - [v4](./v4) is the legacy DMAPI 4 (Used in TGS 4.0.X versions).
+ - [v5](./v5) is the current DMAPI version used by TGS4 >=4.1.
+- [LICENSE](./LICENSE) is the MIT license for the DMAPI.
+
+APIs communicate with TGS in two ways. All versions implement TGS -> DM communication using /world/Topic. DM -> TGS communication, called the bridge method, is different for each version.
diff --git a/code/modules/tgs/core/README.md b/code/modules/tgs/core/README.md
new file mode 100644
index 00000000000..aa3c7a9c9db
--- /dev/null
+++ b/code/modules/tgs/core/README.md
@@ -0,0 +1,8 @@
+# Core DMAPI functions
+
+This folder contains all DMAPI code not directly involved in an API.
+
+- [_definitions.dm](./definitions.dm) contains defines needed across DMAPI internals.
+- [core.dm](./core.dm) contains the implementations of the `/world/proc/TgsXXX()` procs. Many map directly to the `/datum/tgs_api` functions. It also contains the /datum selection and setup code.
+- [datum.dm](./datum.dm) contains the `/datum/tgs_api` declarations that all APIs must implement.
+- [tgs_version.dm](./tgs_version.dm) contains the `/datum/tgs_version` definition
\ No newline at end of file
diff --git a/code/modules/tgs/core/_definitions.dm b/code/modules/tgs/core/_definitions.dm
new file mode 100644
index 00000000000..ebf6d17c2a0
--- /dev/null
+++ b/code/modules/tgs/core/_definitions.dm
@@ -0,0 +1,2 @@
+#define TGS_UNIMPLEMENTED "___unimplemented"
+#define TGS_VERSION_PARAMETER "server_service_version"
diff --git a/code/modules/tgs/core/core.dm b/code/modules/tgs/core/core.dm
new file mode 100644
index 00000000000..41a04733945
--- /dev/null
+++ b/code/modules/tgs/core/core.dm
@@ -0,0 +1,156 @@
+/world/TgsNew(datum/tgs_event_handler/event_handler, minimum_required_security_level = TGS_SECURITY_ULTRASAFE)
+ var/current_api = TGS_READ_GLOBAL(tgs)
+ if(current_api)
+ TGS_ERROR_LOG("API datum already set (\ref[current_api] ([current_api]))! Was TgsNew() called more than once?")
+ return
+
+ if(!(minimum_required_security_level in list(TGS_SECURITY_ULTRASAFE, TGS_SECURITY_SAFE, TGS_SECURITY_TRUSTED)))
+ TGS_ERROR_LOG("Invalid minimum_required_security_level: [minimum_required_security_level]!")
+ return
+
+#ifdef TGS_V3_API
+ if(minimum_required_security_level != TGS_SECURITY_TRUSTED)
+ TGS_WARNING_LOG("V3 DMAPI requires trusted security!")
+ minimum_required_security_level = TGS_SECURITY_TRUSTED
+#endif
+ var/raw_parameter = world.params[TGS_VERSION_PARAMETER]
+ if(!raw_parameter)
+ return
+
+ var/datum/tgs_version/version = new(raw_parameter)
+ if(!version.Valid(FALSE))
+ TGS_ERROR_LOG("Failed to validate DMAPI version parameter: [raw_parameter]!")
+ return
+
+ var/api_datum
+ switch(version.suite)
+ if(3)
+#ifndef TGS_V3_API
+ TGS_ERROR_LOG("Detected V3 API but TGS_V3_API isn't defined!")
+ return
+#else
+ switch(version.minor)
+ if(2)
+ api_datum = /datum/tgs_api/v3210
+#endif
+ if(4)
+ switch(version.minor)
+ if(0)
+ api_datum = /datum/tgs_api/v4
+ if(5)
+ api_datum = /datum/tgs_api/v5
+
+ var/datum/tgs_version/max_api_version = TgsMaximumApiVersion();
+ if(version.suite != null && version.minor != null && version.patch != null && version.deprecated_patch != null && version.deprefixed_parameter > max_api_version.deprefixed_parameter)
+ TGS_ERROR_LOG("Detected unknown API version! Defaulting to latest. Update the DMAPI to fix this problem.")
+ api_datum = /datum/tgs_api/latest
+
+ if(!api_datum)
+ TGS_ERROR_LOG("Found unsupported API version: [raw_parameter]. If this is a valid version please report this, backporting is done on demand.")
+ return
+
+ TGS_INFO_LOG("Activating API for version [version.deprefixed_parameter]")
+
+ if(event_handler && !istype(event_handler))
+ TGS_ERROR_LOG("Invalid parameter for event_handler: [event_handler]")
+ event_handler = null
+
+ var/datum/tgs_api/new_api = new api_datum(event_handler, version)
+
+ TGS_WRITE_GLOBAL(tgs, new_api)
+
+ var/result = new_api.OnWorldNew(minimum_required_security_level)
+ if(!result || result == TGS_UNIMPLEMENTED)
+ TGS_WRITE_GLOBAL(tgs, null)
+ TGS_ERROR_LOG("Failed to activate API!")
+
+/world/TgsMaximumApiVersion()
+ return new /datum/tgs_version("5.x.x")
+
+/world/TgsMinimumApiVersion()
+ return new /datum/tgs_version("3.2.x")
+
+/world/TgsInitializationComplete()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ api.OnInitializationComplete()
+
+/world/proc/TgsTopic(T)
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ var/result = api.OnTopic(T)
+ if(result != TGS_UNIMPLEMENTED)
+ return result
+
+/world/TgsRevision()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ var/result = api.Revision()
+ if(result != TGS_UNIMPLEMENTED)
+ return result
+
+/world/TgsReboot()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ api.OnReboot()
+
+/world/TgsAvailable()
+ return TGS_READ_GLOBAL(tgs) != null
+
+/world/TgsVersion()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ return api.version
+
+/world/TgsApiVersion()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ return api.ApiVersion()
+
+/world/TgsInstanceName()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ var/result = api.InstanceName()
+ if(result != TGS_UNIMPLEMENTED)
+ return result
+
+/world/TgsTestMerges()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ var/result = api.TestMerges()
+ if(result != TGS_UNIMPLEMENTED)
+ return result
+ return list()
+
+/world/TgsEndProcess()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ api.EndProcess()
+
+/world/TgsChatChannelInfo()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ var/result = api.ChatChannelInfo()
+ if(result != TGS_UNIMPLEMENTED)
+ return result
+ return list()
+
+/world/TgsChatBroadcast(message, list/channels)
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ api.ChatBroadcast(message, channels)
+
+/world/TgsTargetedChatBroadcast(message, admin_only)
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ api.ChatTargetedBroadcast(message, admin_only)
+
+/world/TgsChatPrivateMessage(message, datum/tgs_chat_user/user)
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ api.ChatPrivateMessage(message, user)
+
+/world/TgsSecurityLevel()
+ var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
+ if(api)
+ api.SecurityLevel()
diff --git a/code/modules/tgs/core/datum.dm b/code/modules/tgs/core/datum.dm
new file mode 100644
index 00000000000..4d37ed662d1
--- /dev/null
+++ b/code/modules/tgs/core/datum.dm
@@ -0,0 +1,57 @@
+TGS_DEFINE_AND_SET_GLOBAL(tgs, null)
+
+/datum/tgs_api
+ var/datum/tgs_version/version
+ var/datum/tgs_event_handler/event_handler
+
+/datum/tgs_api/New(datum/tgs_event_handler/event_handler, datum/tgs_version/version)
+ . = ..()
+ src.event_handler = event_handler
+ src.version = version
+
+/datum/tgs_api/latest
+ parent_type = /datum/tgs_api/v5
+
+TGS_PROTECT_DATUM(/datum/tgs_api)
+
+/datum/tgs_api/proc/ApiVersion()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/OnWorldNew(datum/tgs_event_handler/event_handler)
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/OnInitializationComplete()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/OnTopic(T)
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/OnReboot()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/InstanceName()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/TestMerges()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/EndProcess()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/Revision()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/ChatChannelInfo()
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/ChatBroadcast(message, list/channels)
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/ChatTargetedBroadcast(message, admin_only)
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/ChatPrivateMessage(message, datum/tgs_chat_user/user)
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/proc/SecurityLevel()
+ return TGS_UNIMPLEMENTED
diff --git a/code/modules/tgs/core/tgs_version.dm b/code/modules/tgs/core/tgs_version.dm
new file mode 100644
index 00000000000..a5dae1241a3
--- /dev/null
+++ b/code/modules/tgs/core/tgs_version.dm
@@ -0,0 +1,28 @@
+/datum/tgs_version/New(raw_parameter)
+ src.raw_parameter = raw_parameter
+ deprefixed_parameter = replacetext(raw_parameter, "/tg/station 13 Server v", "")
+ var/list/version_bits = splittext(deprefixed_parameter, ".")
+
+ suite = text2num(version_bits[1])
+ if(version_bits.len > 1)
+ minor = text2num(version_bits[2])
+ if(version_bits.len > 2)
+ patch = text2num(version_bits[3])
+ if(version_bits.len == 4)
+ deprecated_patch = text2num(version_bits[4])
+
+/datum/tgs_version/proc/Valid(allow_wildcards = FALSE)
+ if(suite == null)
+ return FALSE
+ if(allow_wildcards)
+ return TRUE
+ return !Wildcard()
+
+/datum/tgs_version/Wildcard()
+ return minor == null || patch == null
+
+/datum/tgs_version/Equals(datum/tgs_version/other_version)
+ if(!istype(other_version))
+ return FALSE
+
+ return suite == other_version.suite && minor == other_version.minor && patch == other_version.patch && deprecated_patch == other_version.deprecated_patch
diff --git a/code/modules/tgs/includes.dm b/code/modules/tgs/includes.dm
new file mode 100644
index 00000000000..4018074f4e3
--- /dev/null
+++ b/code/modules/tgs/includes.dm
@@ -0,0 +1,17 @@
+#include "core\_definitions.dm"
+#include "core\core.dm"
+#include "core\datum.dm"
+#include "core\tgs_version.dm"
+
+#ifdef TGS_V3_API
+#include "v3210\api.dm"
+#include "v3210\commands.dm"
+#endif
+
+#include "v4\api.dm"
+#include "v4\commands.dm"
+
+#include "v5\_defines.dm"
+#include "v5\api.dm"
+#include "v5\commands.dm"
+#include "v5\undefs.dm"
diff --git a/code/modules/tgs/v3210/README.md b/code/modules/tgs/v3210/README.md
new file mode 100644
index 00000000000..f96e7cf3b31
--- /dev/null
+++ b/code/modules/tgs/v3210/README.md
@@ -0,0 +1,6 @@
+# DMAPI V3
+
+This DMAPI implements bridge using file output which TGS monitors for.
+
+- [api.dm](./api.dm) contains the bulk of the API code.
+- [commands.dm](./commands.dm) contains functions relating to `/datum/tgs_chat_command`s.
diff --git a/code/modules/tgs/v3210/api.dm b/code/modules/tgs/v3210/api.dm
new file mode 100644
index 00000000000..63823251001
--- /dev/null
+++ b/code/modules/tgs/v3210/api.dm
@@ -0,0 +1,228 @@
+#define REBOOT_MODE_NORMAL 0
+#define REBOOT_MODE_HARD 1
+#define REBOOT_MODE_SHUTDOWN 2
+
+#define SERVICE_WORLD_PARAM "server_service"
+#define SERVICE_INSTANCE_PARAM "server_instance"
+#define SERVICE_PR_TEST_JSON "prtestjob.json"
+#define SERVICE_INTERFACE_DLL "TGDreamDaemonBridge.dll"
+#define SERVICE_INTERFACE_FUNCTION "DDEntryPoint"
+
+#define SERVICE_CMD_HARD_REBOOT "hard_reboot"
+#define SERVICE_CMD_GRACEFUL_SHUTDOWN "graceful_shutdown"
+#define SERVICE_CMD_WORLD_ANNOUNCE "world_announce"
+#define SERVICE_CMD_LIST_CUSTOM "list_custom_commands"
+#define SERVICE_CMD_API_COMPATIBLE "api_compat"
+#define SERVICE_CMD_PLAYER_COUNT "client_count"
+
+#define SERVICE_CMD_PARAM_KEY "serviceCommsKey"
+#define SERVICE_CMD_PARAM_COMMAND "command"
+#define SERVICE_CMD_PARAM_SENDER "sender"
+#define SERVICE_CMD_PARAM_CUSTOM "custom"
+
+#define SERVICE_REQUEST_KILL_PROCESS "killme"
+#define SERVICE_REQUEST_IRC_BROADCAST "irc"
+#define SERVICE_REQUEST_IRC_ADMIN_CHANNEL_MESSAGE "send2irc"
+#define SERVICE_REQUEST_WORLD_REBOOT "worldreboot"
+#define SERVICE_REQUEST_API_VERSION "api_ver"
+
+#define SERVICE_RETURN_SUCCESS "SUCCESS"
+
+/datum/tgs_api/v3210
+ var/reboot_mode = REBOOT_MODE_NORMAL
+ var/comms_key
+ var/instance_name
+ var/originmastercommit
+ var/commit
+ var/list/cached_custom_tgs_chat_commands
+ var/warned_revison = FALSE
+ var/warned_custom_commands = FALSE
+
+/datum/tgs_api/v3210/ApiVersion()
+ return new /datum/tgs_version("3.2.1.3")
+
+/datum/tgs_api/v3210/proc/trim_left(text)
+ for (var/i = 1 to length(text))
+ if (text2ascii(text, i) > 32)
+ return copytext(text, i)
+ return ""
+
+/datum/tgs_api/v3210/proc/trim_right(text)
+ for (var/i = length(text), i > 0, i--)
+ if (text2ascii(text, i) > 32)
+ return copytext(text, 1, i + 1)
+ return ""
+
+/datum/tgs_api/v3210/proc/file2list(filename)
+ return splittext(trim_left(trim_right(file2text(filename))), "\n")
+
+/datum/tgs_api/v3210/OnWorldNew(minimum_required_security_level)
+ . = FALSE
+
+ comms_key = world.params[SERVICE_WORLD_PARAM]
+ instance_name = world.params[SERVICE_INSTANCE_PARAM]
+ if(!instance_name)
+ instance_name = "TG Station Server" //maybe just upgraded
+
+ var/list/logs = file2list(".git/logs/HEAD")
+ if(logs.len)
+ logs = splittext(logs[logs.len - 1], " ")
+ commit = logs[2]
+ logs = file2list(".git/logs/refs/remotes/origin/master")
+ if(logs.len)
+ originmastercommit = splittext(logs[logs.len - 1], " ")[2]
+
+ if(world.system_type != MS_WINDOWS)
+ TGS_ERROR_LOG("This API version is only supported on Windows. Not running on Windows. Aborting initialization!")
+ return
+ ListServiceCustomCommands(TRUE)
+ var/datum/tgs_version/api_version = ApiVersion()
+ ExportService("[SERVICE_REQUEST_API_VERSION] [api_version.deprefixed_parameter]", TRUE)
+ return TRUE
+
+//nothing to do for v3
+/datum/tgs_api/v3210/OnInitializationComplete()
+ return
+
+/datum/tgs_api/v3210/InstanceName()
+ return world.params[SERVICE_INSTANCE_PARAM]
+
+/datum/tgs_api/v3210/proc/ExportService(command, skip_compat_check = FALSE)
+ . = FALSE
+ if(skip_compat_check && !fexists(SERVICE_INTERFACE_DLL))
+ TGS_ERROR_LOG("Service parameter present but no interface DLL detected. This is symptomatic of running a service less than version 3.1! Please upgrade.")
+ return
+ call(SERVICE_INTERFACE_DLL, SERVICE_INTERFACE_FUNCTION)(instance_name, command) //trust no retval
+ return TRUE
+
+/datum/tgs_api/v3210/OnTopic(T)
+ var/list/params = params2list(T)
+ var/their_sCK = params[SERVICE_CMD_PARAM_KEY]
+ if(!their_sCK)
+ return FALSE //continue world/Topic
+
+ if(their_sCK != comms_key)
+ return "Invalid comms key!";
+
+ var/command = params[SERVICE_CMD_PARAM_COMMAND]
+ if(!command)
+ return "No command!"
+
+ switch(command)
+ if(SERVICE_CMD_API_COMPATIBLE)
+ return SERVICE_RETURN_SUCCESS
+ if(SERVICE_CMD_HARD_REBOOT)
+ if(reboot_mode != REBOOT_MODE_HARD)
+ reboot_mode = REBOOT_MODE_HARD
+ TGS_INFO_LOG("Hard reboot requested by service")
+ TGS_NOTIFY_ADMINS("The world will hard reboot at the end of the game. Requested by TGS.")
+ if(SERVICE_CMD_GRACEFUL_SHUTDOWN)
+ if(reboot_mode != REBOOT_MODE_SHUTDOWN)
+ reboot_mode = REBOOT_MODE_SHUTDOWN
+ TGS_INFO_LOG("Shutdown requested by service")
+ TGS_NOTIFY_ADMINS("The world will shutdown at the end of the game. Requested by TGS.")
+ if(SERVICE_CMD_WORLD_ANNOUNCE)
+ var/msg = params["message"]
+ if(!istext(msg) || !msg)
+ return "No message set!"
+ TGS_WORLD_ANNOUNCE(msg)
+ return SERVICE_RETURN_SUCCESS
+ if(SERVICE_CMD_PLAYER_COUNT)
+ return "[TGS_CLIENT_COUNT]"
+ if(SERVICE_CMD_LIST_CUSTOM)
+ return json_encode(ListServiceCustomCommands(FALSE))
+ else
+ var/custom_command_result = HandleServiceCustomCommand(lowertext(command), params[SERVICE_CMD_PARAM_SENDER], params[SERVICE_CMD_PARAM_CUSTOM])
+ if(custom_command_result)
+ return istext(custom_command_result) ? custom_command_result : SERVICE_RETURN_SUCCESS
+ return "Unknown command: [command]"
+
+/datum/tgs_api/v3210/OnReboot()
+ switch(reboot_mode)
+ if(REBOOT_MODE_HARD)
+ TGS_WORLD_ANNOUNCE("Hard reboot triggered, you will automatically reconnect...")
+ EndProcess()
+ if(REBOOT_MODE_SHUTDOWN)
+ TGS_WORLD_ANNOUNCE("The server is shutting down...")
+ EndProcess()
+ else
+ ExportService(SERVICE_REQUEST_WORLD_REBOOT) //just let em know
+
+/datum/tgs_api/v3210/TestMerges()
+ //do the best we can here as the datum can't be completed using the v3 api
+ . = list()
+ if(!fexists(SERVICE_PR_TEST_JSON))
+ return
+ var/list/json = json_decode(file2text(SERVICE_PR_TEST_JSON))
+ if(!json)
+ return
+ for(var/I in json)
+ var/datum/tgs_revision_information/test_merge/tm = new
+ tm.number = text2num(I)
+ var/list/entry = json[I]
+ tm.head_commit = entry["commit"]
+ tm.author = entry["author"]
+ tm.title = entry["title"]
+ . += tm
+
+/datum/tgs_api/v3210/Revision()
+ if(!warned_revison)
+ var/datum/tgs_version/api_version = ApiVersion()
+ TGS_ERROR_LOG("Use of TgsRevision on [api_version.deprefixed_parameter] origin_commit only points to master!")
+ warned_revison = TRUE
+ var/datum/tgs_revision_information/ri = new
+ ri.commit = commit
+ ri.origin_commit = originmastercommit
+ return ri
+
+/datum/tgs_api/v3210/EndProcess()
+ sleep(world.tick_lag) //flush the buffers
+ ExportService(SERVICE_REQUEST_KILL_PROCESS)
+
+/datum/tgs_api/v3210/ChatChannelInfo()
+ return list() // :omegalul:
+
+/datum/tgs_api/v3210/ChatBroadcast(message, list/channels)
+ if(channels)
+ return TGS_UNIMPLEMENTED
+ ChatTargetedBroadcast(message, TRUE)
+ ChatTargetedBroadcast(message, FALSE)
+
+/datum/tgs_api/v3210/ChatTargetedBroadcast(message, admin_only)
+ ExportService("[admin_only ? SERVICE_REQUEST_IRC_ADMIN_CHANNEL_MESSAGE : SERVICE_REQUEST_IRC_BROADCAST] [message]")
+
+/datum/tgs_api/v3210/ChatPrivateMessage(message, datum/tgs_chat_user/user)
+ return TGS_UNIMPLEMENTED
+
+/datum/tgs_api/v3210/SecurityLevel()
+ return TGS_SECURITY_TRUSTED
+
+#undef REBOOT_MODE_NORMAL
+#undef REBOOT_MODE_HARD
+#undef REBOOT_MODE_SHUTDOWN
+
+#undef SERVICE_WORLD_PARAM
+#undef SERVICE_INSTANCE_PARAM
+#undef SERVICE_PR_TEST_JSON
+#undef SERVICE_INTERFACE_DLL
+#undef SERVICE_INTERFACE_FUNCTION
+
+#undef SERVICE_CMD_HARD_REBOOT
+#undef SERVICE_CMD_GRACEFUL_SHUTDOWN
+#undef SERVICE_CMD_WORLD_ANNOUNCE
+#undef SERVICE_CMD_LIST_CUSTOM
+#undef SERVICE_CMD_API_COMPATIBLE
+#undef SERVICE_CMD_PLAYER_COUNT
+
+#undef SERVICE_CMD_PARAM_KEY
+#undef SERVICE_CMD_PARAM_COMMAND
+#undef SERVICE_CMD_PARAM_SENDER
+#undef SERVICE_CMD_PARAM_CUSTOM
+
+#undef SERVICE_REQUEST_KILL_PROCESS
+#undef SERVICE_REQUEST_IRC_BROADCAST
+#undef SERVICE_REQUEST_IRC_ADMIN_CHANNEL_MESSAGE
+#undef SERVICE_REQUEST_WORLD_REBOOT
+#undef SERVICE_REQUEST_API_VERSION
+
+#undef SERVICE_RETURN_SUCCESS
diff --git a/code/modules/tgs/v3210/commands.dm b/code/modules/tgs/v3210/commands.dm
new file mode 100644
index 00000000000..4ccfc1a8a60
--- /dev/null
+++ b/code/modules/tgs/v3210/commands.dm
@@ -0,0 +1,52 @@
+#define SERVICE_JSON_PARAM_HELPTEXT "help_text"
+#define SERVICE_JSON_PARAM_ADMINONLY "admin_only"
+#define SERVICE_JSON_PARAM_REQUIREDPARAMETERS "required_parameters"
+
+/datum/tgs_api/v3210/proc/ListServiceCustomCommands(warnings_only)
+ if(!warnings_only)
+ . = list()
+ var/list/command_name_types = list()
+ var/list/warned_command_names = warnings_only ? list() : null
+ var/warned_about_the_dangers_of_robutussin = !warnings_only
+ for(var/I in typesof(/datum/tgs_chat_command) - /datum/tgs_chat_command)
+ if(!warned_about_the_dangers_of_robutussin)
+ TGS_ERROR_LOG("Custom chat commands in [ApiVersion()] lacks the /datum/tgs_chat_user/sender.channel field!")
+ warned_about_the_dangers_of_robutussin = TRUE
+ var/datum/tgs_chat_command/stc = I
+ var/command_name = initial(stc.name)
+ if(!command_name || findtext(command_name, " ") || findtext(command_name, "'") || findtext(command_name, "\""))
+ if(warnings_only && !warned_command_names[command_name])
+ TGS_ERROR_LOG("Custom command [command_name] can't be used as it is empty or contains illegal characters!")
+ warned_command_names[command_name] = TRUE
+ continue
+
+ if(command_name_types[command_name])
+ if(warnings_only)
+ TGS_ERROR_LOG("Custom commands [command_name_types[command_name]] and [stc] have the same name, only [command_name_types[command_name]] will be available!")
+ continue
+ command_name_types[stc] = command_name
+
+ if(!warnings_only)
+ .[command_name] = list(SERVICE_JSON_PARAM_HELPTEXT = initial(stc.help_text), SERVICE_JSON_PARAM_ADMINONLY = initial(stc.admin_only), SERVICE_JSON_PARAM_REQUIREDPARAMETERS = 0)
+
+/datum/tgs_api/v3210/proc/HandleServiceCustomCommand(command, sender, params)
+ if(!cached_custom_tgs_chat_commands)
+ cached_custom_tgs_chat_commands = list()
+ for(var/I in typesof(/datum/tgs_chat_command) - /datum/tgs_chat_command)
+ var/datum/tgs_chat_command/stc = I
+ cached_custom_tgs_chat_commands[lowertext(initial(stc.name))] = stc
+
+ var/command_type = cached_custom_tgs_chat_commands[command]
+ if(!command_type)
+ return FALSE
+ var/datum/tgs_chat_command/stc = new command_type
+ var/datum/tgs_chat_user/user = new
+ user.friendly_name = sender
+
+ // Discord hack, fix the mention if it's only numbers (fuck you IRC trolls)
+ var/regex/discord_id_regex = regex(@"^[0-9]+$")
+ if(findtext(sender, discord_id_regex))
+ sender = "<@[sender]>"
+
+ user.mention = sender
+ return stc.Run(user, params) || TRUE
diff --git a/code/modules/tgs/v4/README.md b/code/modules/tgs/v4/README.md
new file mode 100644
index 00000000000..78191447b27
--- /dev/null
+++ b/code/modules/tgs/v4/README.md
@@ -0,0 +1,6 @@
+# DMAPI V4
+
+This DMAPI implements bridge requests using file output which TGS monitors for. It has a safe mode restriction.
+
+- [api.dm](./api.dm) contains the bulk of the API code.
+- [commands.dm](./commands.dm) contains functions relating to `/datum/tgs_chat_command`s.
diff --git a/code/modules/tgs/v4/api.dm b/code/modules/tgs/v4/api.dm
new file mode 100644
index 00000000000..0e37a7aa24c
--- /dev/null
+++ b/code/modules/tgs/v4/api.dm
@@ -0,0 +1,309 @@
+#define TGS4_PARAM_INFO_JSON "tgs_json"
+
+#define TGS4_INTEROP_ACCESS_IDENTIFIER "tgs_tok"
+
+#define TGS4_RESPONSE_SUCCESS "tgs_succ"
+
+#define TGS4_TOPIC_CHANGE_PORT "tgs_port"
+#define TGS4_TOPIC_CHANGE_REBOOT_MODE "tgs_rmode"
+#define TGS4_TOPIC_CHAT_COMMAND "tgs_chat_comm"
+#define TGS4_TOPIC_EVENT "tgs_event"
+#define TGS4_TOPIC_INTEROP_RESPONSE "tgs_interop"
+
+#define TGS4_COMM_NEW_PORT "tgs_new_port"
+#define TGS4_COMM_VALIDATE "tgs_validate"
+#define TGS4_COMM_SERVER_PRIMED "tgs_prime"
+#define TGS4_COMM_WORLD_REBOOT "tgs_reboot"
+#define TGS4_COMM_END_PROCESS "tgs_kill"
+#define TGS4_COMM_CHAT "tgs_chat_send"
+
+#define TGS4_PARAMETER_COMMAND "tgs_com"
+#define TGS4_PARAMETER_DATA "tgs_data"
+
+#define TGS4_PORT_CRITFAIL_MESSAGE " Must exit to let watchdog reboot..."
+
+#define EXPORT_TIMEOUT_DS 200
+
+/datum/tgs_api/v4
+ var/access_identifier
+ var/instance_name
+ var/json_path
+ var/chat_channels_json_path
+ var/chat_commands_json_path
+ var/server_commands_json_path
+ var/reboot_mode = TGS_REBOOT_MODE_NORMAL
+ var/security_level
+
+ var/requesting_new_port = FALSE
+
+ var/list/intercepted_message_queue
+
+ var/list/custom_commands
+
+ var/list/cached_test_merges
+ var/datum/tgs_revision_information/cached_revision
+
+ var/export_lock = FALSE
+ var/list/last_interop_response
+
+/datum/tgs_api/v4/ApiVersion()
+ return new /datum/tgs_version("4.0.0.0")
+
+/datum/tgs_api/v4/OnWorldNew(minimum_required_security_level)
+ if(minimum_required_security_level == TGS_SECURITY_ULTRASAFE)
+ TGS_WARNING_LOG("V4 DMAPI requires safe security!")
+ minimum_required_security_level = TGS_SECURITY_SAFE
+
+ json_path = world.params[TGS4_PARAM_INFO_JSON]
+ if(!json_path)
+ TGS_ERROR_LOG("Missing [TGS4_PARAM_INFO_JSON] world parameter!")
+ return
+ var/json_file = file2text(json_path)
+ if(!json_file)
+ TGS_ERROR_LOG("Missing specified json file: [json_path]")
+ return
+ var/cached_json = json_decode(json_file)
+ if(!cached_json)
+ TGS_ERROR_LOG("Failed to decode info json: [json_file]")
+ return
+
+ access_identifier = cached_json["accessIdentifier"]
+ server_commands_json_path = cached_json["serverCommandsJson"]
+
+ if(cached_json["apiValidateOnly"])
+ TGS_INFO_LOG("Validating API and exiting...")
+ Export(TGS4_COMM_VALIDATE, list(TGS4_PARAMETER_DATA = "[minimum_required_security_level]"))
+ del(world)
+
+ security_level = cached_json["securityLevel"]
+ chat_channels_json_path = cached_json["chatChannelsJson"]
+ chat_commands_json_path = cached_json["chatCommandsJson"]
+ instance_name = cached_json["instanceName"]
+
+ ListCustomCommands()
+
+ var/list/revisionData = cached_json["revision"]
+ if(revisionData)
+ cached_revision = new
+ cached_revision.commit = revisionData["commitSha"]
+ cached_revision.origin_commit = revisionData["originCommitSha"]
+
+ cached_test_merges = list()
+ var/list/json = cached_json["testMerges"]
+ for(var/entry in json)
+ var/datum/tgs_revision_information/test_merge/tm = new
+ tm.timestamp = text2num(entry["timeMerged"])
+
+ var/list/revInfo = entry["revision"]
+ if(revInfo)
+ tm.commit = revInfo["commitSha"]
+ tm.origin_commit = revInfo["originCommitSha"]
+
+ tm.title = entry["titleAtMerge"]
+ tm.body = entry["bodyAtMerge"]
+ tm.url = entry["url"]
+ tm.author = entry["author"]
+ tm.number = entry["number"]
+ tm.head_commit = entry["pullRequestRevision"]
+ tm.comment = entry["comment"]
+
+ cached_test_merges += tm
+
+ return TRUE
+
+/datum/tgs_api/v4/OnInitializationComplete()
+ Export(TGS4_COMM_SERVER_PRIMED)
+
+/datum/tgs_api/v4/OnTopic(T)
+ var/list/params = params2list(T)
+ var/their_sCK = params[TGS4_INTEROP_ACCESS_IDENTIFIER]
+ if(!their_sCK)
+ return FALSE //continue world/Topic
+
+ if(their_sCK != access_identifier)
+ return "Invalid comms key!";
+
+ var/command = params[TGS4_PARAMETER_COMMAND]
+ if(!command)
+ return "No command!"
+
+ . = TGS4_RESPONSE_SUCCESS
+
+ switch(command)
+ if(TGS4_TOPIC_CHAT_COMMAND)
+ var/result = HandleCustomCommand(params[TGS4_PARAMETER_DATA])
+ if(result == null)
+ result = "Error running chat command!"
+ return result
+ if(TGS4_TOPIC_EVENT)
+ intercepted_message_queue = list()
+ var/list/event_notification = json_decode(params[TGS4_PARAMETER_DATA])
+ var/list/event_parameters = event_notification["Parameters"]
+
+ var/list/event_call = list(event_notification["Type"])
+ if(event_parameters)
+ event_call += event_parameters
+
+ if(event_handler != null)
+ event_handler.HandleEvent(arglist(event_call))
+
+ . = json_encode(intercepted_message_queue)
+ intercepted_message_queue = null
+ return
+ if(TGS4_TOPIC_INTEROP_RESPONSE)
+ last_interop_response = json_decode(params[TGS4_PARAMETER_DATA])
+ return
+ if(TGS4_TOPIC_CHANGE_PORT)
+ var/new_port = text2num(params[TGS4_PARAMETER_DATA])
+ if (!(new_port > 0))
+ return "Invalid port: [new_port]"
+
+ //the topic still completes, miraculously
+ //I honestly didn't believe byond could do it
+ if(event_handler != null)
+ event_handler.HandleEvent(TGS_EVENT_PORT_SWAP, new_port)
+ if(!world.OpenPort(new_port))
+ return "Port change failed!"
+ return
+ if(TGS4_TOPIC_CHANGE_REBOOT_MODE)
+ var/new_reboot_mode = text2num(params[TGS4_PARAMETER_DATA])
+ if(event_handler != null)
+ event_handler.HandleEvent(TGS_EVENT_REBOOT_MODE_CHANGE, reboot_mode, new_reboot_mode)
+ reboot_mode = new_reboot_mode
+ return
+
+ return "Unknown command: [command]"
+
+/datum/tgs_api/v4/proc/Export(command, list/data, override_requesting_new_port = FALSE)
+ if(!data)
+ data = list()
+ data[TGS4_PARAMETER_COMMAND] = command
+ var/json = json_encode(data)
+
+ while(requesting_new_port && !override_requesting_new_port)
+ sleep(1)
+
+ //we need some port open at this point to facilitate return communication
+ if(!world.port)
+ requesting_new_port = TRUE
+ if(!world.OpenPort(0)) //open any port
+ TGS_ERROR_LOG("Unable to open random port to retrieve new port![TGS4_PORT_CRITFAIL_MESSAGE]")
+ del(world)
+
+ //request a new port
+ export_lock = FALSE
+ var/list/new_port_json = Export(TGS4_COMM_NEW_PORT, list(TGS4_PARAMETER_DATA = "[world.port]"), TRUE) //stringify this on purpose
+
+ if(!new_port_json)
+ TGS_ERROR_LOG("No new port response from server![TGS4_PORT_CRITFAIL_MESSAGE]")
+ del(world)
+
+ var/new_port = new_port_json[TGS4_PARAMETER_DATA]
+ if(!isnum(new_port) || new_port <= 0)
+ TGS_ERROR_LOG("Malformed new port json ([json_encode(new_port_json)])![TGS4_PORT_CRITFAIL_MESSAGE]")
+ del(world)
+
+ if(new_port != world.port && !world.OpenPort(new_port))
+ TGS_ERROR_LOG("Unable to open port [new_port]![TGS4_PORT_CRITFAIL_MESSAGE]")
+ del(world)
+ requesting_new_port = FALSE
+
+ while(export_lock)
+ sleep(1)
+ export_lock = TRUE
+
+ last_interop_response = null
+ fdel(server_commands_json_path)
+ text2file(json, server_commands_json_path)
+
+ for(var/I = 0; I < EXPORT_TIMEOUT_DS && !last_interop_response; ++I)
+ sleep(1)
+
+ if(!last_interop_response)
+ TGS_ERROR_LOG("Failed to get export result for: [json]")
+ else
+ . = last_interop_response
+
+ export_lock = FALSE
+
+/datum/tgs_api/v4/OnReboot()
+ var/list/result = Export(TGS4_COMM_WORLD_REBOOT)
+ if(!result)
+ return
+
+ //okay so the standard TGS4 proceedure is: right before rebooting change the port to whatever was sent to us in the above json's data parameter
+
+ var/port = result[TGS4_PARAMETER_DATA]
+ if(!isnum(port))
+ return //this is valid, server may just want use to reboot
+
+ if(port == 0)
+ //to byond 0 means any port and "none" means close vOv
+ port = "none"
+
+ if(!world.OpenPort(port))
+ TGS_ERROR_LOG("Unable to set port to [port]!")
+
+/datum/tgs_api/v4/InstanceName()
+ return instance_name
+
+/datum/tgs_api/v4/TestMerges()
+ return cached_test_merges.Copy()
+
+/datum/tgs_api/v4/EndProcess()
+ Export(TGS4_COMM_END_PROCESS)
+
+/datum/tgs_api/v4/Revision()
+ return cached_revision
+
+/datum/tgs_api/v4/ChatBroadcast(message, list/channels)
+ var/list/ids
+ if(length(channels))
+ ids = list()
+ for(var/I in channels)
+ var/datum/tgs_chat_channel/channel = I
+ ids += channel.id
+ message = list("message" = message, "channelIds" = ids)
+ if(intercepted_message_queue)
+ intercepted_message_queue += list(message)
+ else
+ Export(TGS4_COMM_CHAT, message)
+
+/datum/tgs_api/v4/ChatTargetedBroadcast(message, admin_only)
+ var/list/channels = list()
+ for(var/I in ChatChannelInfo())
+ var/datum/tgs_chat_channel/channel = I
+ if (!channel.is_private_channel && ((channel.is_admin_channel && admin_only) || (!channel.is_admin_channel && !admin_only)))
+ channels += channel.id
+ message = list("message" = message, "channelIds" = channels)
+ if(intercepted_message_queue)
+ intercepted_message_queue += list(message)
+ else
+ Export(TGS4_COMM_CHAT, message)
+
+/datum/tgs_api/v4/ChatPrivateMessage(message, datum/tgs_chat_user/user)
+ message = list("message" = message, "channelIds" = list(user.channel.id))
+ if(intercepted_message_queue)
+ intercepted_message_queue += list(message)
+ else
+ Export(TGS4_COMM_CHAT, message)
+
+/datum/tgs_api/v4/ChatChannelInfo()
+ . = list()
+ //no caching cause tgs may change this
+ var/list/json = json_decode(file2text(chat_channels_json_path))
+ for(var/I in json)
+ . += DecodeChannel(I)
+
+/datum/tgs_api/v4/proc/DecodeChannel(channel_json)
+ var/datum/tgs_chat_channel/channel = new
+ channel.id = channel_json["id"]
+ channel.friendly_name = channel_json["friendlyName"]
+ channel.connection_name = channel_json["connectionName"]
+ channel.is_admin_channel = channel_json["isAdminChannel"]
+ channel.is_private_channel = channel_json["isPrivateChannel"]
+ channel.custom_tag = channel_json["tag"]
+ return channel
+
+/datum/tgs_api/v4/SecurityLevel()
+ return security_level
diff --git a/code/modules/tgs/v4/commands.dm b/code/modules/tgs/v4/commands.dm
new file mode 100644
index 00000000000..4ca1500167b
--- /dev/null
+++ b/code/modules/tgs/v4/commands.dm
@@ -0,0 +1,41 @@
+/datum/tgs_api/v4/proc/ListCustomCommands()
+ var/results = list()
+ custom_commands = list()
+ for(var/I in typesof(/datum/tgs_chat_command) - /datum/tgs_chat_command)
+ var/datum/tgs_chat_command/stc = new I
+ var/command_name = stc.name
+ if(!command_name || findtext(command_name, " ") || findtext(command_name, "'") || findtext(command_name, "\""))
+ TGS_ERROR_LOG("Custom command [command_name] ([I]) can't be used as it is empty or contains illegal characters!")
+ continue
+
+ if(results[command_name])
+ var/datum/other = custom_commands[command_name]
+ TGS_ERROR_LOG("Custom commands [other.type] and [I] have the same name (\"[command_name]\"), only [other.type] will be available!")
+ continue
+ results += list(list("name" = command_name, "help_text" = stc.help_text, "admin_only" = stc.admin_only))
+ custom_commands[command_name] = stc
+
+ var/commands_file = chat_commands_json_path
+ if(!commands_file)
+ return
+ text2file(json_encode(results), commands_file)
+
+/datum/tgs_api/v4/proc/HandleCustomCommand(command_json)
+ var/list/data = json_decode(command_json)
+ var/command = data["command"]
+ var/user = data["user"]
+ var/params = data["params"]
+
+ var/datum/tgs_chat_user/u = new
+ u.id = user["id"]
+ u.friendly_name = user["friendlyName"]
+ u.mention = user["mention"]
+ u.channel = DecodeChannel(user["channel"])
+
+ var/datum/tgs_chat_command/sc = custom_commands[command]
+ if(sc)
+ var/result = sc.Run(u, params)
+ if(result == null)
+ result = ""
+ return result
+ return "Unknown command: [command]!"
diff --git a/code/modules/tgs/v5/README.md b/code/modules/tgs/v5/README.md
new file mode 100644
index 00000000000..5b48d57a1f0
--- /dev/null
+++ b/code/modules/tgs/v5/README.md
@@ -0,0 +1,8 @@
+# DMAPI V5
+
+This DMAPI implements bridge requests using HTTP GET requests to TGS. It has no security restrictions.
+
+- [_defines.dm](./_defines.dm) contains constant definitions.
+- [api.dm](./api.dm) contains the bulk of the API code.
+- [commands.dm](./commands.dm) contains functions relating to `/datum/tgs_chat_command`s.
+- [undefs.dm](./undefs.dm) Undoes the work of `_defines.dm`.
diff --git a/code/modules/tgs/v5/_defines.dm b/code/modules/tgs/v5/_defines.dm
new file mode 100644
index 00000000000..10bc4cbe406
--- /dev/null
+++ b/code/modules/tgs/v5/_defines.dm
@@ -0,0 +1,99 @@
+#define DMAPI5_PARAM_SERVER_PORT "tgs_port"
+#define DMAPI5_PARAM_ACCESS_IDENTIFIER "tgs_key"
+
+#define DMAPI5_BRIDGE_DATA "data"
+#define DMAPI5_TOPIC_DATA "tgs_data"
+
+#define DMAPI5_BRIDGE_COMMAND_PORT_UPDATE 0
+#define DMAPI5_BRIDGE_COMMAND_STARTUP 1
+#define DMAPI5_BRIDGE_COMMAND_PRIME 2
+#define DMAPI5_BRIDGE_COMMAND_REBOOT 3
+#define DMAPI5_BRIDGE_COMMAND_KILL 4
+#define DMAPI5_BRIDGE_COMMAND_CHAT_SEND 5
+
+#define DMAPI5_PARAMETER_ACCESS_IDENTIFIER "accessIdentifier"
+#define DMAPI5_PARAMETER_CUSTOM_COMMANDS "customCommands"
+
+#define DMAPI5_RESPONSE_ERROR_MESSAGE "errorMessage"
+
+#define DMAPI5_BRIDGE_PARAMETER_COMMAND_TYPE "commandType"
+#define DMAPI5_BRIDGE_PARAMETER_CURRENT_PORT "currentPort"
+#define DMAPI5_BRIDGE_PARAMETER_VERSION "version"
+#define DMAPI5_BRIDGE_PARAMETER_CHAT_MESSAGE "chatMessage"
+#define DMAPI5_BRIDGE_PARAMETER_MINIMUM_SECURITY_LEVEL "minimumSecurityLevel"
+
+#define DMAPI5_BRIDGE_RESPONSE_NEW_PORT "newPort"
+#define DMAPI5_BRIDGE_RESPONSE_RUNTIME_INFORMATION "runtimeInformation"
+
+#define DMAPI5_CHAT_MESSAGE_TEXT "text"
+#define DMAPI5_CHAT_MESSAGE_CHANNEL_IDS "channelIds"
+
+#define DMAPI5_RUNTIME_INFORMATION_ACCESS_IDENTIFIER "accessIdentifier"
+#define DMAPI5_RUNTIME_INFORMATION_SERVER_VERSION "serverVersion"
+#define DMAPI5_RUNTIME_INFORMATION_SERVER_PORT "serverPort"
+#define DMAPI5_RUNTIME_INFORMATION_API_VALIDATE_ONLY "apiValidateOnly"
+#define DMAPI5_RUNTIME_INFORMATION_INSTANCE_NAME "instanceName"
+#define DMAPI5_RUNTIME_INFORMATION_REVISION "revision"
+#define DMAPI5_RUNTIME_INFORMATION_TEST_MERGES "testMerges"
+#define DMAPI5_RUNTIME_INFORMATION_SECURITY_LEVEL "securityLevel"
+
+#define DMAPI5_CHAT_UPDATE_CHANNELS "channels"
+
+#define DMAPI5_TEST_MERGE_TIME_MERGED "timeMerged"
+#define DMAPI5_TEST_MERGE_REVISION "revision"
+#define DMAPI5_TEST_MERGE_TITLE_AT_MERGE "titleAtMerge"
+#define DMAPI5_TEST_MERGE_BODY_AT_MERGE "bodyAtMerge"
+#define DMAPI5_TEST_MERGE_URL "url"
+#define DMAPI5_TEST_MERGE_AUTHOR "author"
+#define DMAPI5_TEST_MERGE_NUMBER "number"
+#define DMAPI5_TEST_MERGE_PULL_REQUEST_REVISION "pullRequestRevision"
+#define DMAPI5_TEST_MERGE_COMMENT "comment"
+
+#define DMAPI5_CHAT_COMMAND_NAME "name"
+#define DMAPI5_CHAT_COMMAND_PARAMS "params"
+#define DMAPI5_CHAT_COMMAND_USER "user"
+
+#define DMAPI5_EVENT_NOTIFICATION_TYPE "type"
+#define DMAPI5_EVENT_NOTIFICATION_PARAMETERS "parameters"
+
+#define DMAPI5_TOPIC_COMMAND_CHAT_COMMAND 0
+#define DMAPI5_TOPIC_COMMAND_EVENT_NOTIFICATION 1
+#define DMAPI5_TOPIC_COMMAND_CHANGE_PORT 2
+#define DMAPI5_TOPIC_COMMAND_CHANGE_REBOOT_STATE 3
+#define DMAPI5_TOPIC_COMMAND_INSTANCE_RENAMED 4
+#define DMAPI5_TOPIC_COMMAND_CHAT_CHANNELS_UPDATE 5
+#define DMAPI5_TOPIC_COMMAND_SERVER_PORT_UPDATE 6
+#define DMAPI5_TOPIC_COMMAND_HEARTBEAT 7
+#define DMAPI5_TOPIC_COMMAND_WATCHDOG_REATTACH 8
+
+#define DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE "commandType"
+#define DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND "chatCommand"
+#define DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION "eventNotification"
+#define DMAPI5_TOPIC_PARAMETER_NEW_PORT "newPort"
+#define DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE "newRebootState"
+#define DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME "newInstanceName"
+#define DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE "chatUpdate"
+#define DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION "newServerVersion"
+
+#define DMAPI5_TOPIC_RESPONSE_COMMAND_RESPONSE_MESSAGE "commandResponseMessage"
+#define DMAPI5_TOPIC_RESPONSE_CHAT_RESPONSES "chatResponses"
+
+#define DMAPI5_REVISION_INFORMATION_COMMIT_SHA "commitSha"
+#define DMAPI5_REVISION_INFORMATION_TIMESTAMP "timestamp"
+#define DMAPI5_REVISION_INFORMATION_ORIGIN_COMMIT_SHA "originCommitSha"
+
+#define DMAPI5_CHAT_USER_ID "id"
+#define DMAPI5_CHAT_USER_FRIENDLY_NAME "friendlyName"
+#define DMAPI5_CHAT_USER_MENTION "mention"
+#define DMAPI5_CHAT_USER_CHANNEL "channel"
+
+#define DMAPI5_CHAT_CHANNEL_ID "id"
+#define DMAPI5_CHAT_CHANNEL_FRIENDLY_NAME "friendlyName"
+#define DMAPI5_CHAT_CHANNEL_CONNECTION_NAME "connectionName"
+#define DMAPI5_CHAT_CHANNEL_IS_ADMIN_CHANNEL "isAdminChannel"
+#define DMAPI5_CHAT_CHANNEL_IS_PRIVATE_CHANNEL "isPrivateChannel"
+#define DMAPI5_CHAT_CHANNEL_TAG "tag"
+
+#define DMAPI5_CUSTOM_CHAT_COMMAND_NAME "name"
+#define DMAPI5_CUSTOM_CHAT_COMMAND_HELP_TEXT "helpText"
+#define DMAPI5_CUSTOM_CHAT_COMMAND_ADMIN_ONLY "adminOnly"
diff --git a/code/modules/tgs/v5/api.dm b/code/modules/tgs/v5/api.dm
new file mode 100644
index 00000000000..704ff873c0a
--- /dev/null
+++ b/code/modules/tgs/v5/api.dm
@@ -0,0 +1,361 @@
+/datum/tgs_api/v5
+ var/server_port
+ var/access_identifier
+
+ var/instance_name
+ var/security_level
+
+ var/reboot_mode = TGS_REBOOT_MODE_NORMAL
+
+ var/list/intercepted_message_queue
+
+ var/list/custom_commands
+
+ var/list/test_merges
+ var/datum/tgs_revision_information/revision
+ var/list/chat_channels
+
+ var/initialized = FALSE
+
+/datum/tgs_api/v5/ApiVersion()
+ return new /datum/tgs_version(
+ #include "interop_version.dm"
+ )
+
+/datum/tgs_api/v5/OnWorldNew(minimum_required_security_level)
+ server_port = world.params[DMAPI5_PARAM_SERVER_PORT]
+ access_identifier = world.params[DMAPI5_PARAM_ACCESS_IDENTIFIER]
+
+ var/datum/tgs_version/api_version = ApiVersion()
+ version = null
+ var/list/bridge_response = Bridge(DMAPI5_BRIDGE_COMMAND_STARTUP, list(DMAPI5_BRIDGE_PARAMETER_MINIMUM_SECURITY_LEVEL = minimum_required_security_level, DMAPI5_BRIDGE_PARAMETER_VERSION = api_version.raw_parameter, DMAPI5_PARAMETER_CUSTOM_COMMANDS = ListCustomCommands()))
+ if(!istype(bridge_response))
+ TGS_ERROR_LOG("Failed initial bridge request!")
+ return FALSE
+
+ var/list/runtime_information = bridge_response[DMAPI5_BRIDGE_RESPONSE_RUNTIME_INFORMATION]
+ if(!istype(runtime_information))
+ TGS_ERROR_LOG("Failed to decode runtime information from bridge response: [json_encode(bridge_response)]!")
+ return FALSE
+
+ if(runtime_information[DMAPI5_RUNTIME_INFORMATION_API_VALIDATE_ONLY])
+ TGS_INFO_LOG("DMAPI validation, exiting...")
+ del(world)
+
+ version = new /datum/tgs_version(runtime_information[DMAPI5_RUNTIME_INFORMATION_SERVER_VERSION])
+ security_level = runtime_information[DMAPI5_RUNTIME_INFORMATION_SECURITY_LEVEL]
+ instance_name = runtime_information[DMAPI5_RUNTIME_INFORMATION_INSTANCE_NAME]
+
+ var/list/revisionData = runtime_information[DMAPI5_RUNTIME_INFORMATION_REVISION]
+ if(istype(revisionData))
+ revision = new
+ revision.commit = revisionData[DMAPI5_REVISION_INFORMATION_COMMIT_SHA]
+ revision.timestamp = revisionData[DMAPI5_REVISION_INFORMATION_TIMESTAMP]
+ revision.origin_commit = revisionData[DMAPI5_REVISION_INFORMATION_ORIGIN_COMMIT_SHA]
+ else
+ TGS_ERROR_LOG("Failed to decode [DMAPI5_RUNTIME_INFORMATION_REVISION] from runtime information!")
+
+ test_merges = list()
+ var/list/test_merge_json = runtime_information[DMAPI5_RUNTIME_INFORMATION_TEST_MERGES]
+ if(istype(test_merge_json))
+ for(var/entry in test_merge_json)
+ var/datum/tgs_revision_information/test_merge/tm = new
+ tm.number = entry[DMAPI5_TEST_MERGE_NUMBER]
+
+ var/list/revInfo = entry[DMAPI5_TEST_MERGE_REVISION]
+ if(revInfo)
+ tm.commit = revisionData[DMAPI5_REVISION_INFORMATION_COMMIT_SHA]
+ tm.origin_commit = revisionData[DMAPI5_REVISION_INFORMATION_ORIGIN_COMMIT_SHA]
+ tm.timestamp = entry[DMAPI5_REVISION_INFORMATION_TIMESTAMP]
+ else
+ TGS_WARNING_LOG("Failed to decode [DMAPI5_TEST_MERGE_REVISION] from test merge #[tm.number]!")
+
+ if(!tm.timestamp)
+ tm.timestamp = entry[DMAPI5_TEST_MERGE_TIME_MERGED]
+
+ tm.title = entry[DMAPI5_TEST_MERGE_TITLE_AT_MERGE]
+ tm.body = entry[DMAPI5_TEST_MERGE_BODY_AT_MERGE]
+ tm.url = entry[DMAPI5_TEST_MERGE_URL]
+ tm.author = entry[DMAPI5_TEST_MERGE_AUTHOR]
+ tm.head_commit = entry[DMAPI5_TEST_MERGE_PULL_REQUEST_REVISION]
+ tm.comment = entry[DMAPI5_TEST_MERGE_COMMENT]
+
+ test_merges += tm
+ else
+ TGS_WARNING_LOG("Failed to decode [DMAPI5_RUNTIME_INFORMATION_TEST_MERGES] from runtime information!")
+
+ chat_channels = list()
+ DecodeChannels(runtime_information)
+
+ initialized = TRUE
+ return TRUE
+
+/datum/tgs_api/v5/proc/RequireInitialBridgeResponse()
+ while(!version)
+ sleep(1)
+
+/datum/tgs_api/v5/OnInitializationComplete()
+ Bridge(DMAPI5_BRIDGE_COMMAND_PRIME)
+
+/datum/tgs_api/v5/proc/TopicResponse(error_message = null)
+ var/list/response = list()
+ response[DMAPI5_RESPONSE_ERROR_MESSAGE] = error_message
+
+ return json_encode(response)
+
+/datum/tgs_api/v5/OnTopic(T)
+ var/list/params = params2list(T)
+ var/json = params[DMAPI5_TOPIC_DATA]
+ if(!json)
+ return FALSE // continue to /world/Topic
+
+ var/list/topic_parameters = json_decode(json)
+ if(!topic_parameters)
+ return TopicResponse("Invalid topic parameters json!");
+
+ if(!initialized)
+ TGS_WARNING_LOG("Missed topic due to not being initialized: [T]")
+ return TRUE // too early to handle, but it's still our responsibility
+
+ var/their_sCK = topic_parameters[DMAPI5_PARAMETER_ACCESS_IDENTIFIER]
+ if(their_sCK != access_identifier)
+ return TopicResponse("Failed to decode [DMAPI5_PARAMETER_ACCESS_IDENTIFIER] from: [json]!");
+
+ var/command = topic_parameters[DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE]
+ if(!isnum(command))
+ return TopicResponse("Failed to decode [DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE] from: [json]!")
+
+ switch(command)
+ if(DMAPI5_TOPIC_COMMAND_CHAT_COMMAND)
+ var/result = HandleCustomCommand(topic_parameters[DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND])
+ if(!result)
+ result = TopicResponse("Error running chat command!")
+ return result
+ if(DMAPI5_TOPIC_COMMAND_EVENT_NOTIFICATION)
+ intercepted_message_queue = list()
+ var/list/event_notification = topic_parameters[DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION]
+ if(!istype(event_notification))
+ return TopicResponse("Invalid [DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION]!")
+
+ var/event_type = event_notification[DMAPI5_EVENT_NOTIFICATION_TYPE]
+ if(!isnum(event_type))
+ return TopicResponse("Invalid or missing [DMAPI5_EVENT_NOTIFICATION_TYPE]!")
+
+ var/list/event_parameters = event_notification[DMAPI5_EVENT_NOTIFICATION_PARAMETERS]
+ if(event_parameters && !istype(event_parameters))
+ return TopicResponse("Invalid or missing [DMAPI5_EVENT_NOTIFICATION_PARAMETERS]!")
+
+ var/list/event_call = list(event_type)
+ if(event_parameters)
+ event_call += event_parameters
+
+ if(event_handler != null)
+ event_handler.HandleEvent(arglist(event_call))
+
+ var/list/response = list()
+ response[DMAPI5_TOPIC_RESPONSE_CHAT_RESPONSES] = intercepted_message_queue
+ intercepted_message_queue = null
+ return json_encode(response)
+ if(DMAPI5_TOPIC_COMMAND_CHANGE_PORT)
+ var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
+ if (!isnum(new_port) || !(new_port > 0))
+ return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]")
+
+ if(event_handler != null)
+ event_handler.HandleEvent(TGS_EVENT_PORT_SWAP, new_port)
+
+ //the topic still completes, miraculously
+ //I honestly didn't believe byond could do it without exploding
+ if(!world.OpenPort(new_port))
+ return TopicResponse("Port change failed!")
+
+ return TopicResponse()
+ if(DMAPI5_TOPIC_COMMAND_CHANGE_REBOOT_STATE)
+ var/new_reboot_mode = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE]
+ if(!isnum(new_reboot_mode))
+ return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE]!")
+
+ if(event_handler != null)
+ event_handler.HandleEvent(TGS_EVENT_REBOOT_MODE_CHANGE, reboot_mode, new_reboot_mode)
+
+ reboot_mode = new_reboot_mode
+ return TopicResponse()
+ if(DMAPI5_TOPIC_COMMAND_INSTANCE_RENAMED)
+ var/new_instance_name = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME]
+ if(!istext(new_instance_name))
+ return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME]!")
+
+ if(event_handler != null)
+ event_handler.HandleEvent(TGS_EVENT_INSTANCE_RENAMED, new_instance_name)
+
+ instance_name = new_instance_name
+ return TopicResponse()
+ if(DMAPI5_TOPIC_COMMAND_CHAT_CHANNELS_UPDATE)
+ var/list/chat_update_json = topic_parameters[DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE]
+ if(!istype(chat_update_json))
+ return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE]!")
+
+ DecodeChannels(chat_update_json)
+ return TopicResponse()
+ if(DMAPI5_TOPIC_COMMAND_SERVER_PORT_UPDATE)
+ var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
+ if (!isnum(new_port) || !(new_port > 0))
+ return TopicResponse("Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]")
+
+ server_port = new_port
+ return TopicResponse()
+ if(DMAPI5_TOPIC_COMMAND_HEARTBEAT)
+ return TopicResponse()
+ if(DMAPI5_TOPIC_COMMAND_WATCHDOG_REATTACH)
+ var/new_port = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_PORT]
+ var/error_message = null
+ if (new_port != null)
+ if (!isnum(new_port) || !(new_port > 0))
+ error_message = "Invalid [DMAPI5_TOPIC_PARAMETER_NEW_PORT]]"
+ else
+ server_port = new_port
+
+ var/new_version_string = topic_parameters[DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION]
+ if (!istext(new_version_string))
+ if(error_message != null)
+ error_message += ", "
+ error_message += "Invalid or missing [DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION]]"
+ else
+ var/datum/tgs_version/new_version = new(new_version_string)
+ if (event_handler)
+ event_handler.HandleEvent(TGS_EVENT_WATCHDOG_REATTACH, new_version)
+
+ version = new_version
+
+ return json_encode(list(DMAPI5_RESPONSE_ERROR_MESSAGE = error_message, DMAPI5_PARAMETER_CUSTOM_COMMANDS = ListCustomCommands()))
+
+ return TopicResponse("Unknown command: [command]")
+
+/datum/tgs_api/v5/proc/Bridge(command, list/data)
+ if(!data)
+ data = list()
+
+ data[DMAPI5_BRIDGE_PARAMETER_COMMAND_TYPE] = command
+ data[DMAPI5_PARAMETER_ACCESS_IDENTIFIER] = access_identifier
+
+ var/json = json_encode(data)
+ var/encoded_json = url_encode(json)
+
+ // This is an infinite sleep until we get a response
+ var/export_response = world.Export("http://127.0.0.1:[server_port]/Bridge?[DMAPI5_BRIDGE_DATA]=[encoded_json]")
+ if(!export_response)
+ TGS_ERROR_LOG("Failed export request: [json]")
+ return
+
+ var/response_json = file2text(export_response["CONTENT"])
+ if(!response_json)
+ TGS_ERROR_LOG("Failed export request, missing content!")
+ return
+
+ var/list/bridge_response = json_decode(response_json)
+ if(!bridge_response)
+ TGS_ERROR_LOG("Failed export request, bad json: [response_json]")
+ return
+
+ var/error = bridge_response[DMAPI5_RESPONSE_ERROR_MESSAGE]
+ if(error)
+ TGS_ERROR_LOG("Failed export request, bad request: [error]")
+ return
+
+ return bridge_response
+
+/datum/tgs_api/v5/OnReboot()
+ var/list/result = Bridge(DMAPI5_BRIDGE_COMMAND_REBOOT)
+ if(!result)
+ return
+
+ //okay so the standard TGS4 proceedure is: right before rebooting change the port to whatever was sent to us in the above json's data parameter
+
+ var/port = result[DMAPI5_BRIDGE_RESPONSE_NEW_PORT]
+ if(!isnum(port))
+ return //this is valid, server may just want use to reboot
+
+ if(port == 0)
+ //to byond 0 means any port and "none" means close vOv
+ port = "none"
+
+ if(!world.OpenPort(port))
+ TGS_ERROR_LOG("Unable to set port to [port]!")
+
+/datum/tgs_api/v5/InstanceName()
+ RequireInitialBridgeResponse()
+ return instance_name
+
+/datum/tgs_api/v5/TestMerges()
+ RequireInitialBridgeResponse()
+ return test_merges.Copy()
+
+/datum/tgs_api/v5/EndProcess()
+ Bridge(DMAPI5_BRIDGE_COMMAND_KILL)
+
+/datum/tgs_api/v5/Revision()
+ RequireInitialBridgeResponse()
+ return revision
+
+/datum/tgs_api/v5/ChatBroadcast(message, list/channels)
+ if(!length(channels))
+ channels = ChatChannelInfo()
+
+ var/list/ids = list()
+ for(var/I in channels)
+ var/datum/tgs_chat_channel/channel = I
+ ids += channel.id
+
+ message = list(DMAPI5_CHAT_MESSAGE_TEXT = message, DMAPI5_CHAT_MESSAGE_CHANNEL_IDS = ids)
+ if(intercepted_message_queue)
+ intercepted_message_queue += list(message)
+ else
+ Bridge(DMAPI5_BRIDGE_COMMAND_CHAT_SEND, list(DMAPI5_BRIDGE_PARAMETER_CHAT_MESSAGE = message))
+
+/datum/tgs_api/v5/ChatTargetedBroadcast(message, admin_only)
+ var/list/channels = list()
+ for(var/I in ChatChannelInfo())
+ var/datum/tgs_chat_channel/channel = I
+ if (!channel.is_private_channel && ((channel.is_admin_channel && admin_only) || (!channel.is_admin_channel && !admin_only)))
+ channels += channel.id
+ message = list(DMAPI5_CHAT_MESSAGE_TEXT = message, DMAPI5_CHAT_MESSAGE_CHANNEL_IDS = channels)
+ if(intercepted_message_queue)
+ intercepted_message_queue += list(message)
+ else
+ Bridge(DMAPI5_BRIDGE_COMMAND_CHAT_SEND, list(DMAPI5_BRIDGE_PARAMETER_CHAT_MESSAGE = message))
+
+/datum/tgs_api/v5/ChatPrivateMessage(message, datum/tgs_chat_user/user)
+ message = list(DMAPI5_CHAT_MESSAGE_TEXT = message, DMAPI5_CHAT_MESSAGE_CHANNEL_IDS = list(user.channel.id))
+ if(intercepted_message_queue)
+ intercepted_message_queue += list(message)
+ else
+ Bridge(DMAPI5_BRIDGE_COMMAND_CHAT_SEND, list(DMAPI5_BRIDGE_PARAMETER_CHAT_MESSAGE = message))
+
+/datum/tgs_api/v5/ChatChannelInfo()
+ RequireInitialBridgeResponse()
+ return chat_channels.Copy()
+
+/datum/tgs_api/v5/proc/DecodeChannels(chat_update_json)
+ var/list/chat_channels_json = chat_update_json[DMAPI5_CHAT_UPDATE_CHANNELS]
+ if(istype(chat_channels_json))
+ chat_channels.Cut()
+ for(var/channel_json in chat_channels_json)
+ var/datum/tgs_chat_channel/channel = DecodeChannel(channel_json)
+ if(channel)
+ chat_channels += channel
+ else
+ TGS_WARNING_LOG("Failed to decode [DMAPI5_CHAT_UPDATE_CHANNELS] from channel update!")
+
+/datum/tgs_api/v5/proc/DecodeChannel(channel_json)
+ var/datum/tgs_chat_channel/channel = new
+ channel.id = channel_json[DMAPI5_CHAT_CHANNEL_ID]
+ channel.friendly_name = channel_json[DMAPI5_CHAT_CHANNEL_FRIENDLY_NAME]
+ channel.connection_name = channel_json[DMAPI5_CHAT_CHANNEL_CONNECTION_NAME]
+ channel.is_admin_channel = channel_json[DMAPI5_CHAT_CHANNEL_IS_ADMIN_CHANNEL]
+ channel.is_private_channel = channel_json[DMAPI5_CHAT_CHANNEL_IS_PRIVATE_CHANNEL]
+ channel.custom_tag = channel_json[DMAPI5_CHAT_CHANNEL_TAG]
+ return channel
+
+/datum/tgs_api/v5/SecurityLevel()
+ RequireInitialBridgeResponse()
+ return security_level
diff --git a/code/modules/tgs/v5/commands.dm b/code/modules/tgs/v5/commands.dm
new file mode 100644
index 00000000000..6d31dd3422d
--- /dev/null
+++ b/code/modules/tgs/v5/commands.dm
@@ -0,0 +1,40 @@
+/datum/tgs_api/v5/proc/ListCustomCommands()
+ var/results = list()
+ custom_commands = list()
+ for(var/I in typesof(/datum/tgs_chat_command) - /datum/tgs_chat_command)
+ var/datum/tgs_chat_command/stc = new I
+ var/command_name = stc.name
+ if(!command_name || findtext(command_name, " ") || findtext(command_name, "'") || findtext(command_name, "\""))
+ TGS_WARNING_LOG("Custom command [command_name] ([I]) can't be used as it is empty or contains illegal characters!")
+ continue
+
+ if(results[command_name])
+ var/datum/other = custom_commands[command_name]
+ TGS_WARNING_LOG("Custom commands [other.type] and [I] have the same name (\"[command_name]\"), only [other.type] will be available!")
+ continue
+ results += list(list(DMAPI5_CUSTOM_CHAT_COMMAND_NAME = command_name, DMAPI5_CUSTOM_CHAT_COMMAND_HELP_TEXT = stc.help_text, DMAPI5_CUSTOM_CHAT_COMMAND_ADMIN_ONLY = stc.admin_only))
+ custom_commands[command_name] = stc
+
+ return results
+
+/datum/tgs_api/v5/proc/HandleCustomCommand(list/command_json)
+ var/command = command_json[DMAPI5_CHAT_COMMAND_NAME]
+ var/user = command_json[DMAPI5_CHAT_COMMAND_USER]
+ var/params = command_json[DMAPI5_CHAT_COMMAND_PARAMS]
+
+ var/datum/tgs_chat_user/u = new
+ u.id = user[DMAPI5_CHAT_USER_ID]
+ u.friendly_name = user[DMAPI5_CHAT_USER_FRIENDLY_NAME]
+ u.mention = user[DMAPI5_CHAT_USER_MENTION]
+ u.channel = DecodeChannel(user[DMAPI5_CHAT_USER_CHANNEL])
+
+ var/datum/tgs_chat_command/sc = custom_commands[command]
+ if(sc)
+ var/text_response = sc.Run(u, params)
+ var/list/topic_response = list()
+ if(!istext(text_response))
+ TGS_ERROR_LOG("Custom command [command] should return a string! Got: \"[text_response]\"")
+ text_response = null
+ topic_response[DMAPI5_TOPIC_RESPONSE_COMMAND_RESPONSE_MESSAGE] = text_response
+ return json_encode(topic_response)
+ return TopicResponse("Unknown custom chat command: [command]!")
diff --git a/code/modules/tgs/v5/interop_version.dm b/code/modules/tgs/v5/interop_version.dm
new file mode 100644
index 00000000000..c7bf62ecae3
--- /dev/null
+++ b/code/modules/tgs/v5/interop_version.dm
@@ -0,0 +1 @@
+"5.3.0"
diff --git a/code/modules/tgs/v5/undefs.dm b/code/modules/tgs/v5/undefs.dm
new file mode 100644
index 00000000000..5885a60e75c
--- /dev/null
+++ b/code/modules/tgs/v5/undefs.dm
@@ -0,0 +1,99 @@
+#undef DMAPI5_PARAM_SERVER_PORT
+#undef DMAPI5_PARAM_ACCESS_IDENTIFIER
+
+#undef DMAPI5_BRIDGE_DATA
+#undef DMAPI5_TOPIC_DATA
+
+#undef DMAPI5_BRIDGE_COMMAND_PORT_UPDATE
+#undef DMAPI5_BRIDGE_COMMAND_STARTUP
+#undef DMAPI5_BRIDGE_COMMAND_PRIME
+#undef DMAPI5_BRIDGE_COMMAND_REBOOT
+#undef DMAPI5_BRIDGE_COMMAND_KILL
+#undef DMAPI5_BRIDGE_COMMAND_CHAT_SEND
+
+#undef DMAPI5_PARAMETER_ACCESS_IDENTIFIER
+#undef DMAPI5_PARAMETER_CUSTOM_COMMANDS
+
+#undef DMAPI5_RESPONSE_ERROR_MESSAGE
+
+#undef DMAPI5_BRIDGE_PARAMETER_COMMAND_TYPE
+#undef DMAPI5_BRIDGE_PARAMETER_CURRENT_PORT
+#undef DMAPI5_BRIDGE_PARAMETER_VERSION
+#undef DMAPI5_BRIDGE_PARAMETER_CHAT_MESSAGE
+#undef DMAPI5_BRIDGE_PARAMETER_MINIMUM_SECURITY_LEVEL
+
+#undef DMAPI5_BRIDGE_RESPONSE_NEW_PORT
+#undef DMAPI5_BRIDGE_RESPONSE_RUNTIME_INFORMATION
+
+#undef DMAPI5_CHAT_MESSAGE_TEXT
+#undef DMAPI5_CHAT_MESSAGE_CHANNEL_IDS
+
+#undef DMAPI5_RUNTIME_INFORMATION_ACCESS_IDENTIFIER
+#undef DMAPI5_RUNTIME_INFORMATION_SERVER_VERSION
+#undef DMAPI5_RUNTIME_INFORMATION_SERVER_PORT
+#undef DMAPI5_RUNTIME_INFORMATION_API_VALIDATE_ONLY
+#undef DMAPI5_RUNTIME_INFORMATION_INSTANCE_NAME
+#undef DMAPI5_RUNTIME_INFORMATION_REVISION
+#undef DMAPI5_RUNTIME_INFORMATION_TEST_MERGES
+#undef DMAPI5_RUNTIME_INFORMATION_SECURITY_LEVEL
+
+#undef DMAPI5_CHAT_UPDATE_CHANNELS
+
+#undef DMAPI5_TEST_MERGE_TIME_MERGED
+#undef DMAPI5_TEST_MERGE_REVISION
+#undef DMAPI5_TEST_MERGE_TITLE_AT_MERGE
+#undef DMAPI5_TEST_MERGE_BODY_AT_MERGE
+#undef DMAPI5_TEST_MERGE_URL
+#undef DMAPI5_TEST_MERGE_AUTHOR
+#undef DMAPI5_TEST_MERGE_NUMBER
+#undef DMAPI5_TEST_MERGE_PULL_REQUEST_REVISION
+#undef DMAPI5_TEST_MERGE_COMMENT
+
+#undef DMAPI5_CHAT_COMMAND_NAME
+#undef DMAPI5_CHAT_COMMAND_PARAMS
+#undef DMAPI5_CHAT_COMMAND_USER
+
+#undef DMAPI5_EVENT_NOTIFICATION_TYPE
+#undef DMAPI5_EVENT_NOTIFICATION_PARAMETERS
+
+#undef DMAPI5_TOPIC_COMMAND_CHAT_COMMAND
+#undef DMAPI5_TOPIC_COMMAND_EVENT_NOTIFICATION
+#undef DMAPI5_TOPIC_COMMAND_CHANGE_PORT
+#undef DMAPI5_TOPIC_COMMAND_CHANGE_REBOOT_STATE
+#undef DMAPI5_TOPIC_COMMAND_INSTANCE_RENAMED
+#undef DMAPI5_TOPIC_COMMAND_CHAT_CHANNELS_UPDATE
+#undef DMAPI5_TOPIC_COMMAND_SERVER_PORT_UPDATE
+#undef DMAPI5_TOPIC_COMMAND_HEARTBEAT
+#undef DMAPI5_TOPIC_COMMAND_WATCHDOG_REATTACH
+
+#undef DMAPI5_TOPIC_PARAMETER_COMMAND_TYPE
+#undef DMAPI5_TOPIC_PARAMETER_CHAT_COMMAND
+#undef DMAPI5_TOPIC_PARAMETER_EVENT_NOTIFICATION
+#undef DMAPI5_TOPIC_PARAMETER_NEW_PORT
+#undef DMAPI5_TOPIC_PARAMETER_NEW_REBOOT_STATE
+#undef DMAPI5_TOPIC_PARAMETER_NEW_INSTANCE_NAME
+#undef DMAPI5_TOPIC_PARAMETER_CHAT_UPDATE
+#undef DMAPI5_TOPIC_PARAMETER_NEW_SERVER_VERSION
+
+#undef DMAPI5_TOPIC_RESPONSE_COMMAND_RESPONSE_MESSAGE
+#undef DMAPI5_TOPIC_RESPONSE_CHAT_RESPONSES
+
+#undef DMAPI5_REVISION_INFORMATION_COMMIT_SHA
+#undef DMAPI5_REVISION_INFORMATION_TIMESTAMP
+#undef DMAPI5_REVISION_INFORMATION_ORIGIN_COMMIT_SHA
+
+#undef DMAPI5_CHAT_USER_ID
+#undef DMAPI5_CHAT_USER_FRIENDLY_NAME
+#undef DMAPI5_CHAT_USER_MENTION
+#undef DMAPI5_CHAT_USER_CHANNEL
+
+#undef DMAPI5_CHAT_CHANNEL_ID
+#undef DMAPI5_CHAT_CHANNEL_FRIENDLY_NAME
+#undef DMAPI5_CHAT_CHANNEL_CONNECTION_NAME
+#undef DMAPI5_CHAT_CHANNEL_IS_ADMIN_CHANNEL
+#undef DMAPI5_CHAT_CHANNEL_IS_PRIVATE_CHANNEL
+#undef DMAPI5_CHAT_CHANNEL_TAG
+
+#undef DMAPI5_CUSTOM_CHAT_COMMAND_NAME
+#undef DMAPI5_CUSTOM_CHAT_COMMAND_HELP_TEXT
+#undef DMAPI5_CUSTOM_CHAT_COMMAND_ADMIN_ONLY