ChatGPT DeMod

Hides moderation results during conversations with ChatGPT

// ==UserScript==
// @name         ChatGPT DeMod
// @namespace    pl.4as.chatgpt
// @version      5.1
// @description  Hides moderation results during conversations with ChatGPT
// @author       4as
// @match        *://chatgpt.com/*
// @match        *://chat.openai.com/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
	'use strict';
	const target_window = typeof(unsafeWindow) === 'undefined' ? window : unsafeWindow;
	const original_fetch = target_window.fetch;

	const DEMOD_ID = 'demod-cont';
	const DEMOD_KEY = 'DeModState';
	var is_on = false;
	var is_over = false;

	const ButtonState = {
		DISABLED: 0,
		OFF: 1,
		ON: 2,
	};

	var demod_button;
	function updateDeModState() {
		if (is_on) {
			updateButton(demod_button, ButtonState.ON, "DeMod:");
		} else {
			updateButton(demod_button, ButtonState.OFF, "DeMod:");
		}
	}

	const ModerationResult = {
		UNKNOWN: 0,
		SAFE: 1,
		FLAGGED: 2,
		BLOCKED: 3,
	};

	var demod_status;
	function updateDeModMessageState(mod_result) {
		if (demod_status === null)
			return;

		switch (mod_result) {
		case ModerationResult.UNKNOWN:
			demod_status.style.border = '0px';
			demod_status.textContent = "Latest: None";
			demod_status.style.backgroundColor = '#9A9A9A';
			break;
		case ModerationResult.SAFE:
			demod_status.style.border = '0px';
			demod_status.textContent = "Latest: Safe";
			demod_status.style.backgroundColor = '#4CAF50';
			break;
		case ModerationResult.FLAGGED:
			demod_status.style.border = '1px dotted white';
			demod_status.textContent = "Latest: Flagged";
			demod_status.style.backgroundColor = '#ACA950';
			break;
		case ModerationResult.BLOCKED:
			demod_status.style.border = '1px solid white';
			demod_status.textContent = "Latest: BLOCKED";
			demod_status.style.backgroundColor = '#AF4C50';
			break;
		}
	}

	function updateButton(button, state, label) {
		if (button === null)
			return;

		if (is_over) {
			button.style.height = 'auto';
			button.style.border = '0px';
			button.style.padding = '4px 12px';
			button.style.opacity = 1;
			button.style.borderRadius = '4px';
			switch (state) {
			case ButtonState.DISABLED:
				button.style.opacity = 0.5;
				button.style.backgroundColor = '#AAAAAA';
				button.textContent = label + " N/A";
				break;
			case ButtonState.OFF:
				button.style.backgroundColor = '#AF4C50';
				button.textContent = label + " Off";
				break;
			case ButtonState.ON:
				button.style.border = '1px dotted white';
				button.style.padding = '3px 11px';
				button.style.backgroundColor = '#4CAF50';
				button.textContent = label + " On";
				break;
			}
		} else {
			button.textContent = "";
			button.style.height = '6px';
			button.style.padding = '0px';
			button.style.opacity = 1;
			button.style.borderRadius = '0px';
			switch (state) {
			case ButtonState.DISABLED:
				button.style.opacity = 0.5;
				button.style.backgroundColor = '#AAAAAA';
				break;
			case ButtonState.OFF:
				button.style.backgroundColor = '#AF4C50';
				break;
			case ButtonState.ON:
				button.style.border = '1px dotted white';
				button.style.backgroundColor = '#4CAF50';
				break;
			}
		}
	}

	function hasFlagged(text) {
		return text.match(/(\"blocked\"|\"flagged\"): ?true/ig);
	}
	function hasBlocked(text) {
		return text.match(/(\"blocked\"): ?true/ig);
	}

	function clearFlagging(text) {
		// repeated replacement to ensure the style stays consistant
		text = text.replaceAll(/\"flagged\": true/ig, "\"flagged\": false");
		text = text.replaceAll(/\"blocked\": true/ig, "\"blocked\": false");
		text = text.replaceAll(/\"flagged\":true/ig, "\"flagged\":false");
		text = text.replaceAll(/\"blocked\":true/ig, "\"blocked\":false");
		return text;
	}

	const ConversationType = {
		UNKNOWN: 0,
		INIT: 1,
		PROMPT: 2,
	};

	// DeMod state control
	function getDeModState() {
		var state = target_window.localStorage.getItem(DEMOD_KEY);
		if (state == null)
			return true;
		return (state == "false") ? false : true;
	}

	function setDeModState(demod_on) {
		target_window.localStorage.setItem(DEMOD_KEY, demod_on);
	}

	// Interceptors shared data
	const DONE = "[DONE]";
	var init_cache = null;
	var backup_cache = null;

	var response_blocked = false;
	var payload = null;
	var last_response = null;
	var last_conv_id = null;
	var mod_result = ModerationResult.UNKNOWN;
	var sequence_shift = 0; //each sequence in the generated response has an id and all ids need to match, so DeMod shifts them to insert own messages if needed.

	var decoder = new TextDecoder();
	var encoder = new TextEncoder();
	function decodeData(data) {
		if (typeof data == 'string') {
			return data;
		} else if (data.byteLength != undefined) {
			return decoder.decode(new Uint8Array(data));
		}
		return null;
	}

	function cloneRequest(request, fetch_url, method, body) {
		return new Request(fetch_url, {
			method: method,
			headers: request.headers,
			body: JSON.stringify(body),
			referrer: request.referrer,
			referrerPolicy: request.referrerPolicy,
			mode: request.mode,
			credentials: request.credentials,
			cache: request.cache,
			redirect: request.redirect,
			integrity: request.integrity,
		});
	}

	function cloneEvent(event, new_data) {
		return new MessageEvent('message', {
			data: new_data,
			origin: event.origin,
			lastEventId: event.lastEventId,
			source: event.source,
			ports: event.ports
		});
	}

	function redirectConversations(url, conversation_id) {
		var idx = url.indexOf("/textdocs");
		if (idx !== -1)
			return url.substring(0, idx);

		idx = url.indexOf("/conversations");
		if (idx === -1)
			return url;
		return url.substring(0, idx) + "/conversation/" + conversation_id;
	}

	function parseLatest(redownload_text) {
		var latest = null;
		var redownload_object = null;
		try {
			redownload_object = JSON.parse(redownload_text);
		} catch (e) {
			console.log("[DEMOD] Failed to parse re-downloaded response.");
		}
		if (redownload_object !== null && redownload_object.hasOwnProperty('mapping')) {
			var latest_time = 0;
			for (var map_key in redownload_object.mapping) {
				var map_obj = redownload_object.mapping[map_key];
				if (map_obj.hasOwnProperty('message') && map_obj.message != null
					 && map_obj.message.hasOwnProperty('create_time') && map_obj.message.create_time > latest_time) {
					latest = map_obj.message;
				}
			}
		}

		return latest;
	}

	async function redownloadLatest() {
		var original_request = init_cache;
		if (original_request === null) {
			if (last_conv_id === null)
				return null;
			original_request = backup_cache;
		}

		var fetch_url = null;
		if (typeof(original_request[0]) !== 'string') {
			fetch_url = redirectConversations(original_request[0].href, last_conv_id);
			original_request[0] = cloneRequest(original_request[0], fetch_url, "GET", "");
		} else {
			fetch_url = redirectConversations(original_request[0], last_conv_id);
			original_request[0] = fetch_url;
			original_request[1].method = "GET";
			delete original_request[1].body;
		}

		var latest = null;
		var init_redownload = original_fetch(...original_request);
		var redownload_result = await init_redownload;
		if (redownload_result.ok) {
			var redownload_text = await redownload_result.text();
			latest = parseLatest(redownload_text);
		}

		if (latest === null) {
			await new Promise(r => setTimeout(r, 3000));

			init_redownload = original_fetch(...original_request);
			redownload_result = await init_redownload;
			if (redownload_result.ok) {
				redownload_text = await redownload_result.text();
				latest = parseLatest(redownload_text);
			}
		}

		if (latest !== null)
			console.log("[DEMOD] Latest response redownloaded successfully.");

		return latest;
	}

	class ChatPayload {
		data;
		message;

		constructor() {
			this.data = {
				"p": "",
				"o": "patch",
				"v": [{
						"p": "/message/content/parts/0",
						"o": "append",
						"v": ""
					}, {
						"p": "/message/status",
						"o": "replace",
						"v": "finished_successfully"
					}, {
						"p": "/message/end_turn",
						"o": "replace",
						"v": true
					}, {
						"p": "/message/metadata",
						"o": "append",
						"v": {
							"is_complete": true,
							"finish_details": {
								"type": "stop",
								"stop_tokens": [200002]
							}
						}
					}
				]
			}
		}

		getData() {
			return "event: delta\ndata: " + JSON.stringify(this.data) + "\n\n";
		}

		update(chunk_data) {
			if (ChatPayload.isPatch(chunk_data)) {
				this.data = chunk_data;
				if (this.message !== null)
					this.setMessage(this.message);
			}
		}

		setText(text) {
			var v = this.data.v;
			for (let i = 0; i < v.length; ++i) {
				var entry = v[i];
				if (entry.hasOwnProperty("p") && entry.p.indexOf("parts") !== -1) {
					entry.o = "replace";
					entry.v = text;
				}
			}
		}

		setMessage(message) {
			var v = this.data.v;
			for (let i = v.length - 1; i > -1; --i) {
				var value = v[i];
				if (value.hasOwnProperty("p") && value.p.indexOf("parts") !== -1) {
					v.splice(i, 1);
				}
			}

			var parts = message.content.parts;
			for (let i = parts.length - 1; i > -1; --i) {
				var part = parts[i];
				v.push({
					"p": "/message/content/parts/" + i,
					"o": "replace",
					"v": part
				});
			}
		}

		static isPatch(chunk_data) {
			return chunk_data.hasOwnProperty("o") && chunk_data.o === "patch" && chunk_data.hasOwnProperty("v");
		}

		static getConversationId(chunk_data) {
			if (chunk_data.hasOwnProperty('conversation_id'))
				return chunk_data.conversation_id;
			if (chunk_data.hasOwnProperty('v') && chunk_data.v.hasOwnProperty('conversation_id'))
				return chunk_data.v.conversation_id;
			return null;
		}
	}

	class ChatResponse {
		chunk;
		chunk_start;
		payload;
		conversation_id;
		is_done = false;
		is_blocked = false;
		handle_latest = false;
		mod_result = ModerationResult.SAFE;
		queue = [];
		constructor(existing_payload, decoded_chunk, download_latest) {
			this.payload = existing_payload;
			this.chunk = decoded_chunk;
			this.chunk_start = this.chunk.indexOf("data: ");
			this.handle_latest = download_latest;
		}
		async process(current_blocked) {
			this.is_blocked = current_blocked;

			if (this.chunk_start == -1) {
				this.queue.push(this.chunk);
				return;
			}

			if (hasFlagged(this.chunk) || ChatPayload.isPatch(this.chunk) || (this.is_blocked && this.chunk.indexOf(DONE) !== -1)) {
				while (this.chunk_start != -1 && !this.is_done) {
					var chunk_end = this.chunk.indexOf("\n", this.chunk_start);
					if (chunk_end == -1)
						chunk_end = this.chunk.length - 1;
					var chunk_text = this.chunk.substring(this.chunk_start + 5, chunk_end).trim();

					if (chunk_text === DONE) {
						this.is_done = true;
						if (this.handle_latest && this.is_blocked) {
							console.log("[DEMOD] Blocked response finished, attempting to reload it from history.");
							var latest = await redownloadLatest();
							if (latest !== null) {
								this.payload.setMessage(latest);
								this.queue.push(this.payload.getData());
							} else {
								this.payload.setText("DeMod: Request completed, but DeMod failed to access the history. Try refreshing the conversation instead.");
								this.queue.push(this.payload.getData());
							}
						}

					} else {
						var chunk_data = null;
						try {
							chunk_data = JSON.parse(chunk_text);
							var conv_id = ChatPayload.getConversationId(chunk_data);
							if (conv_id !== null) {
								this.conversation_id = conv_id;
								last_conv_id = conv_id;
							}
						} catch (e) {}

						if (chunk_data !== null) {
							if (chunk_data.hasOwnProperty('moderation_response')) {
								var has_flag = chunk_data.moderation_response.flagged === true;
								var has_block = chunk_data.moderation_response.blocked === true;
								if (has_flag || has_block) {
									if (has_flag) {
										if (this.mod_result !== ModerationResult.BLOCKED)
											this.mod_result = ModerationResult.FLAGGED;
									}
									console.log("[DEMOD] Received chunk contains flagging/blocking properties, clearing");
									chunk_data.moderation_response.flagged = false;
									chunk_data.moderation_response.blocked = false;
								}

								if (has_block) {
									console.log("[DEMOD] Message has been BLOCKED. Waiting for ChatGPT to finalize the request...");
									this.mod_result = ModerationResult.BLOCKED;
									this.is_blocked = true;
									this.payload.setText("DeMod: Moderation has intercepted the response and is actively blocking it. Waiting for ChatGPT to finalize the request so DeMod can fetch it from the conversation's history...");
									//this.queue.push(this.payload.getData());
								}
							}
						}
					}

					this.chunk_start = this.chunk.indexOf("data: ", chunk_end + 1);
				}

				var cleaned = clearFlagging(this.chunk);
				this.queue.push(cleaned);
			} else {
				this.queue.push(this.chunk);
			}

			return true;
		}
	}

	class ChatEvent {
		event;
		response_data;
		response_object;
		response_body;
		response = null;
		sequence_id;
		constructor(current_payload, event) {
			this.event = event;
			this.response_data = decodeData(event.data);
			this.response_object = JSON.parse(this.response_data);
			this.sequence_id = this.response_object.sequenceId;
			if (this.has_body) {
				this.response_body = atob(this.response_object.data.body);
				this.response = new ChatResponse(current_payload, this.response_body, false);
			}
		}

		get is_valid() {
			return this.response != null;
		}
		get conversation_id() {
			return this.response.conversation_id;
		}
		get has_body() {
			return this.response_object != null && this.response_object.type == "message" && this.response_object.dataType == "json" && this.response_object.data.body != null;
		}
		get is_blocked() {
			return this.response.is_blocked;
		}
		get is_done() {
			return this.response.is_done;
		}
		get mod_result() {
			return this.response.mod_result;
		}
		get payload() {
			if (this.is_valid)
				return this.response.payload;
			else
				return null;
		}

		async process(current_blocked) {
			await this.response.process(current_blocked);

			var data = "";
			for (const entry of this.response.queue) {
				data += entry;
			}

			this.response_body = data;
		}

		getEvent() {
			this.response_object.sequenceId = this.sequence_id;
			this.response_object.data.body = btoa(this.response_body);
			var updated_data = JSON.stringify(this.response_object);
			return cloneEvent(this.event, updated_data);
		}

		clone() {
			var copy = new ChatEvent(this.payload, this.event);
			copy.response_body = this.response_body;
			return copy;
		}
	}

	// Intercepter for old fetch() based communication
	const intercepter_fetch = async function (target, this_arg, args) {
		if (!is_on) {
			return target.apply(this_arg, args);
		}

		var original_arg = args;
		var fetch_url = args[0];
		var is_request = false;
		if (typeof(fetch_url) !== 'string') {
			fetch_url = fetch_url.href;
			is_request = true;
		}

		if (fetch_url.indexOf('/share/create') != -1) {
			console.log("[DEMOD] Share request detected, blocking.");
			return new Response("", {
				status: 404,
				statusText: "Not found"
			});
		}

		var is_conversation = fetch_url.indexOf('/complete') !== -1 || (fetch_url.indexOf('/conversation') !== -1 && fetch_url.indexOf('/conversations') === -1);
		var convo_type = ConversationType.UNKNOWN;
		if (is_conversation) {
			if (fetch_url.indexOf("/gen_title") != -1) {
				var init_url = fetch_url.replace("/gen_title", "");
				if (is_request) {
					console.log("[DEMOD] Generating title (Request).");
					args = cloneRequest(args[0], init_url, "GET", null);
					args.headers.delete("Content-Type");
				} else {
					console.log("[DEMOD] Generating title (basic).");
					args = JSON.parse(JSON.stringify(args));
					args[0] = init_url;
					args[1].method = "GET";
					delete args[1].headers["Content-Type"];
					delete args[1].body;
				}
			}

			var conv_request = null;
			if (is_request) {
				if (args[0] !== undefined && args[0].hasOwnProperty('text') && (typeof args[0].text === 'function')) {
					conv_request = await args[0].text();
				}
			} else {
				if (args[1] !== undefined && args[1].hasOwnProperty('body')) {
					conv_request = args[1].body;
				}
			}

			if (conv_request) {
				convo_type = ConversationType.PROMPT;
				var conv_body = JSON.parse(conv_request);

				if (is_request) {
					args[0] = cloneRequest(args[0], fetch_url, args[0].method, conv_body);
				} else {
					args[1].body = JSON.stringify(conv_body);
				}
			} else {
				convo_type = ConversationType.INIT;
				init_cache = args;
			}
		} else if (fetch_url.indexOf('/conversations') !== -1) {
			backup_cache = JSON.parse(JSON.stringify(args));
		}

		var original_promise = target.apply(this_arg, original_arg);

		if (is_conversation) {
			var original_result = await original_promise;

			if (!original_result.ok) {
				return original_result;
			}

			switch (convo_type) {
			case ConversationType.PROMPT: {

					payload = new ChatPayload();
					sequence_shift = 0;
					last_response = null;
					response_blocked = false;
					mod_result = ModerationResult.SAFE;
					updateDeModMessageState(mod_result);

					console.log("[DEMOD] Processing basic prompted conversation. Scanning for moderation results...");
					const stream = new ReadableStream({
						async start(controller) {
							var reader = original_result.body.getReader();

							while (true) {
								const {
									done,
									value
								} = await reader.read();

								var raw_chunk = value || new Uint8Array;
								var chunk = decoder.decode(raw_chunk);
								var response = new ChatResponse(payload, chunk, true);

								await response.process(response_blocked);

								if (mod_result < response.mod_result) {
									mod_result = response.mod_result;
									updateDeModMessageState(mod_result);
								}
								response_blocked = response.is_blocked;
								payload = response.payload;

								for (const entry of response.queue) {
									const encoded_chunk = encoder.encode(entry);
									controller.enqueue(encoded_chunk);
								}

								if (response.is_done || done) {
									controller.close();
									break;
								}
							}
						},
					});

					return new Response(stream, {
						status: original_result.status,
						statusText: original_result.statusText,
						headers: original_result.headers,
					});
					break;
				}
			case ConversationType.INIT: {
					console.log("[DEMOD] Processing conversation initialization. Checking if the conversation has existing moderation results.");

					var convo_init = await original_result.text();
					convo_init = clearFlagging(convo_init);

					updateDeModMessageState(ModerationResult.UNKNOWN);

					return new Response(convo_init, {
						status: original_result.status,
						statusText: original_result.statusText,
						headers: original_result.headers,
					});
					break;
				}
			}
		}

		return original_promise;
	}

	const intercepter = new Proxy(original_fetch, {
		apply: intercepter_fetch
	});
	target_window.fetch = intercepter;

	// Interceptor for new WebSocket communication (credit to WebSocket Logger for making this possible)
	var original_websocket = target_window.WebSocket;
	target_window.WebSocket = new Proxy(original_websocket, {
		construct: function (target, args, newTarget) {
			var ws = new target(...args);
			console.log("[DEMOD] WebSocket interceptor created, connecting to: " + args[0]);

			var buffer = [];

			async function processMessage(original_onmessage, event) {
				if (!is_on)
					original_onmessage(event);

				var response = new ChatEvent(payload, event);
				if (response.is_valid) {
					await response.process(response_blocked);

					if (mod_result < response.mod_result) {
						mod_result = response.mod_result;
						updateDeModMessageState(mod_result);
					}

					response_blocked = response.is_blocked;
					payload = response.payload;

					if (response.has_body) {
						last_response = response.clone();
						last_response.sequence_id += sequence_shift;
					}

					if (response_blocked) {
						if (response.is_done) {
							if (last_response != null) {
								console.log("[DEMOD] Response blocked, redownloading from history.");
								var latest = await redownloadLatest();
								last_response.replace(latest);
								buffer.push(last_response);
								sequence_shift++;
							}
						}
					}

					response.sequence_id += sequence_shift;
					buffer.push(response);

					var entry;
					try {
						for (entry of buffer) {
							var entry_event = entry.getEvent();
							original_onmessage(entry_event);
						}
						buffer.length = 0;
					} catch (e) {
						console.log("[DEMOD] Failed to send parsed response: " + entry.response_data + "\n\nWith body: " + entry.response_body);
					}
				} else {
					original_onmessage(event);
				}
			};

			var ws_proxy = {
				set: function (target, prop, v) {
					if (prop == 'onmessage') {
						var original_onmessage = v;
						v = (e) => processMessage(original_onmessage, e);
					}
					return (target[prop] = v);
				}
			};

			return new Proxy(ws, ws_proxy);
		}
	});

	var demod_init = async function () {
		if (document.getElementById(DEMOD_ID) || !document.body)
			return;

		// Adding the "hover" area for the DeMod button.
		const demod_div = document.createElement('div');
		demod_div.setAttribute('id', DEMOD_ID);
		demod_div.style.position = 'fixed';
		demod_div.style.top = '0px';
		demod_div.style.left = '50%';
		demod_div.style.transform = 'translate(-50%, 0%)';
		demod_div.style.width = '254px';
		demod_div.style.height = '24px';
		demod_div.style.display = 'inline-block';
		demod_div.style.verticalAlign = 'top';
		demod_div.style.zIndex = 999;

		// Adding the actual DeMod button
		demod_button = document.createElement('button');
		demod_button.style.color = 'white';
		demod_button.style.height = '6px';
		demod_button.style.width = '124px';
		demod_button.style.border = 'none';
		demod_button.style.cursor = 'pointer';
		demod_button.style.outline = 'none';
		demod_button.style.display = 'inline-block';
		demod_button.style.verticalAlign = 'top';

		demod_div.appendChild(demod_button);

		const demod_space = document.createElement('div');
		demod_space.style.width = '4px';
		demod_space.style.display = 'inline-block';
		demod_space.style.verticalAlign = 'top';

		demod_div.appendChild(demod_space);

		// Adding the last message status indicator
		demod_status = document.createElement('div');
		demod_status.style.color = 'white';
		demod_status.style.height = '6px';
		demod_status.style.border = '0px';
		demod_status.style.padding = '0px';
		demod_status.style.width = '124px';
		demod_status.style.fontSize = '0px';
		demod_status.style.border = 'none';
		demod_status.style.outline = 'none';
		demod_status.style.display = 'inline-block';
		demod_status.style.verticalAlign = 'top';
		demod_status.style.textAlign = 'center';
		demod_status.style.backgroundColor = '#9A9A9A';
		demod_status.textContent = "Latest: None";

		demod_div.appendChild(demod_status);

		demod_div.onmouseover = function () {
			is_over = true;
			demod_status.style.fontSize = '10px';
			demod_status.style.height = '32px';
			demod_status.style.padding = '7px 3px';
			updateDeModState();
		};
		demod_div.onmouseout = function () {
			is_over = false;
			demod_status.style.fontSize = '0px';
			demod_status.style.height = '6px';
			demod_status.style.padding = '0px';
			updateDeModState();
		};

		demod_button.addEventListener('click', () => {
			is_on = !is_on;
			setDeModState(is_on);
			updateDeModState();
		});

		document.body.appendChild(demod_div);
		is_on = getDeModState();
		updateDeModState();
		console.log("[DEMOD] DeMod UI attached.");
	};

	if (document.readyState === 'loading') {
		target_window.addEventListener("DOMContentLoaded", demod_init);
	} else {
		demod_init();
	}

	const observer = new MutationObserver(demod_init);
	observer.observe(document.documentElement || document.body, {
		childList: true,
		subtree: true
	});

	window.addEventListener('popstate', demod_init);
})();