diff --git a/code/modules/client/client procs.dm b/code/modules/client/client procs.dm index 24df219632..b98f707aef 100644 --- a/code/modules/client/client procs.dm +++ b/code/modules/client/client procs.dm @@ -178,6 +178,7 @@ GLOB.directory[ckey] = src GLOB.ahelp_tickets.ClientLogin(src) + GLOB.mhelp_tickets.ClientLogin(src) //Admin Authorisation holder = admin_datums[ckey] @@ -263,6 +264,7 @@ holder.owner = null GLOB.admins -= src GLOB.ahelp_tickets.ClientLogout(src) + GLOB.mhelp_tickets.ClientLogout(src) GLOB.directory -= ckey GLOB.clients -= src return ..() diff --git a/code/modules/mentor/mentor.dm b/code/modules/mentor/mentor.dm new file mode 100644 index 0000000000..bf3259d61a --- /dev/null +++ b/code/modules/mentor/mentor.dm @@ -0,0 +1,245 @@ +/client + var/datum/mentor/mentorholder = null + +var/list/mentor_datums = list() + +var/list/mentor_verbs_default = list( + /client/proc/cmd_mentor_ticket_panel, + /client/proc/cmd_mentor_say, + /client/proc/cmd_dementor +) + +/datum/mentor + var/client/owner = null + +/datum/mentor/New(ckey) + if(!ckey) + error("Mentor datum created without a ckey argument. Datum has been deleted") + qdel(src) + return + mentor_datums[ckey] = src + +/datum/mentor/proc/associate(client/C) + if(istype(C)) + owner = C + owner.mentorholder = src + owner.add_mentor_verbs() + GLOB.mentors |= C + +/datum/mentor/proc/disassociate() + if(owner) + GLOB.mentors -= owner + owner.remove_mentor_verbs() + owner.mentorholder = null + mentor_datums[owner.ckey] = null + qdel(src) + +/client/proc/add_mentor_verbs() + if(mentorholder) + verbs += mentor_verbs_default + +/client/proc/remove_mentor_verbs() + if(mentorholder) + verbs -= mentor_verbs_default + +/client/proc/make_mentor() + set category = "Special Verbs" + set name = "Make Mentor" + if(!holder) + to_chat(src, "Error: Only administrators may use this command.") + return + var/list/client/targets[0] + for(var/client/T in GLOB.clients) + targets["[T.key]"] = T + var/target = tgui_input_list(src,"Who do you want to make a mentor?","Make Mentor", sortList(targets)) + if(!target) + return + var/client/C = targets[target] + if(has_mentor_powers(C) || C.deadmin_holder) // If an admin is deadminned you could mentor them and that will cause fuckery if they readmin + to_chat(src, "Error: They already have mentor powers.") + return + var/datum/mentor/M = new /datum/mentor(C.ckey) + M.associate(C) + to_chat(C, "You have been granted mentorship.") + to_chat(src, "You have made [C] a mentor.") + log_admin("[key_name(src)] made [key_name(C)] a mentor.") + feedback_add_details("admin_verb","Make Mentor") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! + +/client/proc/unmake_mentor() + set category = "Special Verbs" + set name = "Unmake Mentor" + if(!holder) + to_chat(src, "Error: Only administrators may use this command.") + return + var/list/client/targets[0] + for(var/client/T in GLOB.mentors) + targets["[T.key]"] = T + var/target = tgui_input_list(src,"Which mentor do you want to unmake?","Unmake Mentor", sortList(targets)) + if(!target) + return + var/client/C = targets[target] + C.mentorholder.disassociate() + to_chat(C, "Your mentorship has been revoked.") + to_chat(src, "You have revoked [C]'s mentorship.") + log_admin("[key_name(src)] revoked [key_name(C)]'s mentorship.") + feedback_add_details("admin_verb","Unmake Mentor") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! + +/client/proc/cmd_mentor_say(msg as text) + set category = "Admin" + set name ="Mentorsay" + + //check rights + if (!has_mentor_powers(src)) + return + + msg = sanitize(msg) + if (!msg) + return + + log_admin("Mentorsay: [key_name(src)]: [msg]") + + for(var/client/C in GLOB.mentors) + to_chat(C, create_text_tag("mentor", "MENTOR:", C) + " [src]: [msg]") + for(var/client/C in GLOB.admins) + to_chat(C, create_text_tag("mentor", "MENTOR:", C) + " [src]: [msg]") + +/proc/mentor_commands(href, href_list, client/C) + if(href_list["mhelp"]) + var/mhelp_ref = href_list["mhelp"] + var/datum/mentor_help/MH = locate(mhelp_ref) + if (MH && istype(MH, /datum/mentor_help)) + MH.Action(href_list["mhelp_action"]) + else + to_chat(C, "Ticket [mhelp_ref] has been deleted!") + + if (href_list["mhelp_tickets"]) + GLOB.mhelp_tickets.BrowseTickets(text2num(href_list["mhelp_tickets"])) + + +/datum/mentor/Topic(href, href_list) + ..() + if (usr.client != src.owner || (!usr.client.mentorholder)) + log_admin("[key_name(usr)] tried to illegally use mentor functions.") + message_admins("[usr.key] tried to illegally use mentor functions.") + return + + mentor_commands(href, href_list, usr) + +/client/proc/cmd_dementor() + set category = "Admin" + set name = "De-mentor" + + if(tgui_alert(usr, "Confirm self-dementor for the round? You can't re-mentor yourself without someone promoting you.","Dementor",list("Yes","No")) == "Yes") + src.mentorholder.disassociate() + +/client/proc/cmd_mhelp_reply(whom) + if(prefs.muted & MUTE_ADMINHELP) + to_chat(src, "Error: Mentor-PM: You are unable to use admin PM-s (muted).") + return + var/client/C + if(istext(whom)) + C = GLOB.directory[whom] + else if(istype(whom,/client)) + C = whom + if(!C) + if(has_mentor_powers(src)) + to_chat(src, "Error: Mentor-PM: Client not found.") + return + + var/datum/mentor_help/MH = C.current_mentorhelp + + if(MH) + message_mentors("[src] has started replying to [C]'s mentor help.") + var/msg = tgui_input_text(src,"Message:", "Private message to [C]") + if (!msg) + message_mentors("[src] has cancelled their reply to [C]'s mentor help.") + return + cmd_mentor_pm(whom, msg, MH) + +/proc/has_mentor_powers(client/C) + return C.holder || C.mentorholder + +/client/proc/cmd_mentor_pm(whom, msg, datum/mentor_help/MH) + set category = "Admin" + set name = "Mentor-PM" + set hidden = 1 + + if(prefs.muted & MUTE_ADMINHELP) + to_chat(src, "Error: Mentor-PM: You are unable to use admin PM-s (muted).") + return + + //Not a mentor and no open ticket + if(!has_mentor_powers(src) && !current_mentorhelp) + to_chat(src, "You can no longer reply to this ticket, please open another one by using the Mentorhelp verb if need be.") + to_chat(src, "Message: [msg]") + return + + var/client/recipient + + if(istext(whom)) + recipient = GLOB.directory[whom] + + else if(istype(whom,/client)) + recipient = whom + + //get message text, limit it's length.and clean/escape html + if(!msg) + msg = tgui_input_text(src,"Message:", "Mentor-PM to [whom]") + + if(!msg) + return + + if(prefs.muted & MUTE_ADMINHELP) + to_chat(src, "Error: Mentor-PM: You are unable to use admin PM-s (muted).") + return + + if(!recipient) + if(has_mentor_powers(src)) + to_chat(src, "Error:Mentor-PM: Client not found.") + to_chat(src, msg) + else + log_admin("Mentorhelp: [key_name(src)]: [msg]") + current_mentorhelp.MessageNoRecipient(msg) + return + + //Has mentor powers but the recipient no longer has an open ticket + if(has_mentor_powers(src) && !recipient.current_mentorhelp) + to_chat(src, "You can no longer reply to this ticket.") + to_chat(src, "Message: [msg]") + return + + if (src.handle_spam_prevention(msg,MUTE_ADMINHELP)) + return + + msg = trim(sanitize(copytext(msg,1,MAX_MESSAGE_LEN))) + if(!msg) + return + + var/interaction_message = "Mentor-PM from-[src] to-[recipient]: [msg]" + + if (recipient.current_mentorhelp && !has_mentor_powers(recipient)) + recipient.current_mentorhelp.AddInteraction(interaction_message) + if (src.current_mentorhelp && !has_mentor_powers(src)) + src.current_mentorhelp.AddInteraction(interaction_message) + + // It's a little fucky if they're both mentors, but while admins may need to adminhelp I don't really see any reason a mentor would have to mentorhelp since you can literally just ask any other mentors online + if (has_mentor_powers(recipient) && has_mentor_powers(src)) + if (recipient.current_mentorhelp) + recipient.current_mentorhelp.AddInteraction(interaction_message) + if (src.current_mentorhelp) + src.current_mentorhelp.AddInteraction(interaction_message) + + to_chat(recipient, "Mentor-PM from-[src]: [msg]") + to_chat(src, "Mentor-PM to-[recipient]: [msg]") + + log_admin("[key_name(src)]->[key_name(recipient)]: [msg]") + + if(recipient.is_preference_enabled(/datum/client_preference/play_mentorhelp_ping)) + recipient << 'sound/effects/mentorhelp.mp3' + + for(var/client/C in GLOB.mentors) + if (C != recipient && C != src) + to_chat(C, interaction_message) + for(var/client/C in GLOB.admins) + if (C != recipient && C != src) + to_chat(C, interaction_message) \ No newline at end of file diff --git a/code/modules/mentor/mentorhelp.dm b/code/modules/mentor/mentorhelp.dm new file mode 100644 index 0000000000..e5f7ac49d8 --- /dev/null +++ b/code/modules/mentor/mentorhelp.dm @@ -0,0 +1,415 @@ +/client/var/datum/mentor_help/current_mentorhelp + +// +//TICKET MANAGER +// + +GLOBAL_DATUM_INIT(mhelp_tickets, /datum/mentor_help_tickets, new) + +/datum/mentor_help_tickets + var/list/active_tickets = list() + var/list/resolved_tickets = list() + + var/obj/effect/statclick/mticket_list/astatclick = new(null, null, AHELP_ACTIVE) + var/obj/effect/statclick/mticket_list/rstatclick = new(null, null, AHELP_RESOLVED) + +/datum/mentor_help_tickets/Destroy() + QDEL_LIST(active_tickets) + QDEL_LIST(resolved_tickets) + QDEL_NULL(astatclick) + QDEL_NULL(rstatclick) + return ..() + +//private +/datum/mentor_help_tickets/proc/ListInsert(datum/mentor_help/new_ticket) + var/list/mticket_list + switch(new_ticket.state) + if(AHELP_ACTIVE) + mticket_list = active_tickets + if(AHELP_RESOLVED) + mticket_list = resolved_tickets + else + CRASH("Invalid ticket state: [new_ticket.state]") + var/num_closed = mticket_list.len + if(num_closed) + for(var/I in 1 to num_closed) + var/datum/mentor_help/MH = mticket_list[I] + if(MH.id > new_ticket.id) + mticket_list.Insert(I, new_ticket) + return + mticket_list += new_ticket + +//opens the ticket listings, only two states here +/datum/mentor_help_tickets/proc/BrowseTickets(state) + var/list/l2b + var/title + switch(state) + if(AHELP_ACTIVE) + l2b = active_tickets + title = "Active Tickets" + if(AHELP_RESOLVED) + l2b = resolved_tickets + title = "Resolved Tickets" + if(!l2b) + return + var/list/dat = list("[title]") + dat += "Refresh

" + for(var/datum/mentor_help/MH as anything in l2b) + dat += "Ticket #[MH.id]: [MH.initiator_ckey]: [MH.name]
" + + usr << browse(dat.Join(), "window=mhelp_list[state];size=600x480") + +//Tickets statpanel +/datum/mentor_help_tickets/proc/stat_entry() + var/num_disconnected = 0 + stat("Active Tickets:", astatclick.update("[active_tickets.len]")) + for(var/datum/mentor_help/MH as anything in active_tickets) + if(MH.initiator) + stat("#[MH.id]. [MH.initiator_ckey]:", MH.statclick.update()) + else + ++num_disconnected + if(num_disconnected) + stat("Disconnected:", astatclick.update("[num_disconnected]")) + stat("Resolved Tickets:", rstatclick.update("[resolved_tickets.len]")) + +//Reassociate still open ticket if one exists +/datum/mentor_help_tickets/proc/ClientLogin(client/C) + C.current_mentorhelp = CKey2ActiveTicket(C.ckey) + if(C.current_mentorhelp) + C.current_mentorhelp.AddInteraction("Client reconnected.") + C.current_mentorhelp.initiator = C + +//Dissasociate ticket +/datum/mentor_help_tickets/proc/ClientLogout(client/C) + if(C.current_mentorhelp) + C.current_mentorhelp.AddInteraction("Client disconnected.") + C.current_mentorhelp.initiator = null + C.current_mentorhelp = null + +//Get a ticket given a ckey +/datum/mentor_help_tickets/proc/CKey2ActiveTicket(ckey) + for(var/datum/admin_help/MH as anything in active_tickets) + if(MH.initiator_ckey == ckey) + return MH + +// +//TICKET LIST STATCLICK +// + +/obj/effect/statclick/mticket_list + var/current_state + +/obj/effect/statclick/mticket_list/New(loc, name, state) + current_state = state + ..() + +/obj/effect/statclick/mticket_list/Click() + GLOB.mhelp_tickets.BrowseTickets(current_state) + +// +//TICKET DATUM +// + +/datum/mentor_help + var/id + var/name + var/state = AHELP_ACTIVE + + var/opened_at + var/closed_at + + var/client/initiator //semi-misnomer, it's the person who ahelped/was bwoinked + var/initiator_ckey + var/initiator_key_name + + var/list/_interactions //use AddInteraction() or, preferably, admin_ticket_log() + + var/obj/effect/statclick/ahelp/statclick + + var/static/ticket_counter = 0 + +//call this on its own to create a ticket, don't manually assign current_mentorhelp +//msg is the title of the ticket: usually the ahelp text +/datum/mentor_help/New(msg, client/C) + //clean the input msg + msg = sanitize(copytext(msg,1,MAX_MESSAGE_LEN)) + if(!msg || !C || !C.mob) + qdel(src) + return + + id = ++ticket_counter + opened_at = world.time + + name = msg + + initiator = C + initiator_ckey = C.ckey + initiator_key_name = key_name(initiator, FALSE, TRUE) + if(initiator.current_mentorhelp) //This is a bug + log_debug("Ticket erroneously left open by code") + initiator.current_mentorhelp.AddInteraction("Ticket erroneously left open by code") + initiator.current_mentorhelp.Resolve() + initiator.current_mentorhelp = src + + statclick = new(null, src) + _interactions = list() + + log_admin("Mentorhelp: [key_name(C)]: [msg]") + MessageNoRecipient(msg) + //show it to the person adminhelping too + to_chat(C, "Mentor-PM to-Mentors: [name]") + + GLOB.mhelp_tickets.active_tickets += src + +/datum/mentor_help/Destroy() + RemoveActive() + GLOB.mhelp_tickets.resolved_tickets -= src + return ..() + +/datum/mentor_help/proc/AddInteraction(formatted_message) + _interactions += "[gameTimestamp()]: [formatted_message]" + +//private +/datum/mentor_help/proc/ClosureLinks(ref_src) + if(!ref_src) + ref_src = "\ref[src]" + . = " (RSLVE)" + +//private +/datum/mentor_help/proc/LinkedReplyName(ref_src) + if(!ref_src) + ref_src = "\ref[src]" + return "[initiator_ckey]" + +//private +/datum/mentor_help/proc/TicketHref(msg, ref_src, action = "ticket") + if(!ref_src) + ref_src = "\ref[src]" + return "[msg]" + +//message from the initiator without a target, all people with mentor powers will see this +/datum/mentor_help/proc/MessageNoRecipient(msg) + var/ref_src = "\ref[src]" + var/chat_msg = "(ESCALATE) Ticket [TicketHref("#[id]", ref_src)]: [LinkedReplyName(ref_src)]: [msg]" + AddInteraction("[LinkedReplyName(ref_src)]: [msg]") + for (var/client/C in GLOB.mentors) + if (C.is_preference_enabled(/datum/client_preference/play_mentorhelp_ping)) + C << 'sound/effects/mentorhelp.mp3' + for (var/client/C in GLOB.admins) + if (C.is_preference_enabled(/datum/client_preference/play_mentorhelp_ping)) + C << 'sound/effects/mentorhelp.mp3' + message_mentors(chat_msg) + +//Reopen a closed ticket +/datum/mentor_help/proc/Reopen() + if(state == AHELP_ACTIVE) + to_chat(usr, "This ticket is already open.") + return + + if(GLOB.mhelp_tickets.CKey2ActiveTicket(initiator_ckey)) + to_chat(usr, "This user already has an active ticket, cannot reopen this one.") + return + + statclick = new(null, src) + GLOB.mhelp_tickets.active_tickets += src + GLOB.mhelp_tickets.resolved_tickets -= src + switch(state) + if(AHELP_RESOLVED) + feedback_dec("mhelp_resolve") + state = AHELP_ACTIVE + closed_at = null + if(initiator) + initiator.current_mentorhelp = src + + AddInteraction("Reopened by [usr.ckey]") + if(initiator) + to_chat(initiator, "Ticket [TicketHref("#[id]")] was reopened by [usr.ckey].") + var/msg = "Ticket [TicketHref("#[id]")] reopened by [usr.ckey]." + message_mentors(msg) + log_admin(msg) + feedback_inc("mhelp_reopen") + TicketPanel() //can only be done from here, so refresh it + +//private +/datum/mentor_help/proc/RemoveActive() + if(state != AHELP_ACTIVE) + return + closed_at = world.time + QDEL_NULL(statclick) + GLOB.mhelp_tickets.active_tickets -= src + if(initiator && initiator.current_mentorhelp == src) + initiator.current_mentorhelp = null + +//Mark open ticket as resolved/legitimate, returns mentorhelp verb +/datum/mentor_help/proc/Resolve(silent = FALSE) + if(state != AHELP_ACTIVE) + return + RemoveActive() + state = AHELP_RESOLVED + GLOB.mhelp_tickets.ListInsert(src) + + AddInteraction("Resolved by [usr.ckey].") + if(initiator) + to_chat(initiator, "Ticket [TicketHref("#[id]")] was marked resolved by [usr.ckey].") + if(!silent) + feedback_inc("mhelp_resolve") + var/msg = "Ticket [TicketHref("#[id]")] resolved by [usr.ckey]" + message_mentors(msg) + log_admin(msg) + +//Show the ticket panel +/datum/mentor_help/proc/TicketPanel() + var/list/dat = list("Ticket #[id]") + var/ref_src = "\ref[src]" + dat += "

Mentor Help Ticket #[id]: [LinkedReplyName(ref_src)]

" + dat += "State: " + switch(state) + if(AHELP_ACTIVE) + dat += "OPEN" + if(AHELP_RESOLVED) + dat += "RESOLVED" + else + dat += "UNKNOWN" + dat += "[GLOB.TAB][TicketHref("Refresh", ref_src)]" + if(state != AHELP_ACTIVE) + dat += "[GLOB.TAB][TicketHref("Reopen", ref_src, "reopen")]" + dat += "

Opened at: [gameTimestamp(wtime = opened_at)] (Approx [(world.time - opened_at) / 600] minutes ago)" + if(closed_at) + dat += "
Closed at: [gameTimestamp(wtime = closed_at)] (Approx [(world.time - closed_at) / 600] minutes ago)" + dat += "

" + if(initiator) + dat += "Actions: [Context(ref_src)]
" + else + dat += "DISCONNECTED[GLOB.TAB][ClosureLinks(ref_src)]
" + dat += "
Log:

" + for(var/I in _interactions) + dat += "[I]
" + + usr << browse(dat.Join(), "window=mhelp[id];size=620x480") + +//Kick ticket to admins +/datum/mentor_help/proc/Escalate() + if(tgui_alert(usr, "Really escalate this ticket to admins? No mentors will ever be able to interact with it again if you do.","Escalate",list("Yes","No")) != "Yes") + return + if (src.initiator == null) // You can't escalate a mentorhelp of someone who's logged out because it won't create the adminhelp properly + to_chat(usr, "Error: client not found, unable to escalate.") + return + var/datum/admin_help/AH = new /datum/admin_help(src.name, src.initiator, FALSE) + message_mentors("[usr.ckey] escalated Ticket [TicketHref("#[id]")]") + log_admin("[key_name(usr)] escalated mentorhelp [src.name]") + to_chat(src.initiator, "[usr.ckey] escalated your mentorhelp to admins.") + AH._interactions = src._interactions + GLOB.mhelp_tickets.active_tickets -= src + GLOB.mhelp_tickets.resolved_tickets -= src + qdel(src) + +/datum/mentor_help/proc/Context(ref_src) + if(!ref_src) + ref_src = "\ref[src]" + if(state == AHELP_ACTIVE) + . += ClosureLinks(ref_src) + if(state != AHELP_RESOLVED) + . += " (ESCALATE)" + +//Forwarded action from admin/Topic OR mentor/Topic depending on which rank the caller has +/datum/mentor_help/proc/Action(action) + switch(action) + if("ticket") + TicketPanel() + if("reply") + usr.client.cmd_mhelp_reply(initiator) + if("resolve") + Resolve() + if("reopen") + Reopen() + if("escalate") + Escalate() + +// +// TICKET STATCLICK +// + +/obj/effect/statclick/mhelp + var/datum/mentor_help/mhelp_datum + +/obj/effect/statclick/mhelp/New(loc, datum/mentor_help/MH) + mhelp_datum = MH + ..(loc) + +/obj/effect/statclick/mhelp/update() + return ..(mhelp_datum.name) + +/obj/effect/statclick/mhelp/Click() + mhelp_datum.TicketPanel() + +/obj/effect/statclick/mhelp/Destroy() + mhelp_datum = null + return ..() + +// +// CLIENT PROCS +// + +/client/verb/mentorhelp(msg as text) + set category = "Admin" + set name = "Mentorhelp" + + if(say_disabled) //This is here to try to identify lag problems + to_chat(usr, "Speech is currently admin-disabled.") + return + + //handle muting and automuting + if(prefs.muted & MUTE_ADMINHELP) + to_chat(src, "Error: Mentor-PM: You cannot send adminhelps (Muted).") + return + if(handle_spam_prevention(msg,MUTE_ADMINHELP)) + return + + if(!msg) + return + + //remove out adminhelp verb temporarily to prevent spamming of admins. + src.verbs -= /client/verb/mentorhelp + spawn(600) + src.verbs += /client/verb/mentorhelp // 1 minute cool-down for mentorhelps + + feedback_add_details("admin_verb","Mentorhelp") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! + if(current_mentorhelp) + if(tgui_alert(usr, "You already have a ticket open. Is this for the same issue?","Duplicate?",list("Yes","No")) != "No") + if(current_mentorhelp) + log_admin("Mentorhelp: [key_name(src)]: [msg]") + current_mentorhelp.MessageNoRecipient(msg) + to_chat(usr, "Mentor-PM to-Mentors: [msg]") + return + else + to_chat(usr, "Ticket not found, creating new one...") + else + current_mentorhelp.AddInteraction("[usr.ckey] opened a new ticket.") + current_mentorhelp.Resolve() + + new /datum/mentor_help(msg, src, FALSE) + +//admin proc +/client/proc/cmd_mentor_ticket_panel() + set name = "Mentor Ticket List" + set category = "Admin" + + var/browse_to + + switch(tgui_input_list(usr, "Display which ticket list?", "List Choice", list("Active Tickets", "Resolved Tickets"))) + if("Active Tickets") + browse_to = AHELP_ACTIVE + if("Resolved Tickets") + browse_to = AHELP_RESOLVED + else + return + + GLOB.mhelp_tickets.BrowseTickets(browse_to) + +/proc/message_mentors(var/msg) + msg = "Mentor: [msg]" + + for(var/client/C in GLOB.mentors) + to_chat(C, msg) + for(var/client/C in GLOB.admins) + to_chat(C, msg) \ No newline at end of file