git » sdk » commit 10f8b25

Initial support for inbound moderator actions

author Stephen Paul Weber
2024-12-14 19:17:12 UTC
committer Stephen Paul Weber
2024-12-18 17:17:22 UTC
parent 4da441962caef87aecbf773bba6615fb6345fc87

Initial support for inbound moderator actions

snikket/Chat.hx +6 -0
snikket/ChatMessage.hx +16 -0
snikket/Client.hx +23 -0
snikket/Message.hx +25 -8
snikket/ModerationAction.hx +17 -0
snikket/Persistence.hx +3 -0
snikket/persistence/Dummy.hx +13 -0
snikket/persistence/Sqlite.hx +41 -7
snikket/persistence/browser.js +23 -1

diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index a8a9da7..fc369b4 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -135,6 +135,8 @@ abstract class Chat {
 						}));
 					case ReactionUpdateStanza(update):
 						persistence.storeReaction(client.accountId(), update, (m)->{});
+					case ModerateMessageStanza(action):
+						client.moderateMessage(action);
 					default:
 						// ignore
 				}
@@ -1030,6 +1032,10 @@ class Channel extends Chat {
 						promises.push(new thenshim.Promise((resolve, reject) -> {
 							persistence.storeReaction(client.accountId(), update, (_) -> resolve(null));
 						}));
+					case ModerateMessageStanza(action):
+						promises.push(new thenshim.Promise((resolve, reject) -> {
+							client.moderateMessage(action).then((_) -> resolve(null));
+						}));
 					default:
 						// ignore
 				}
diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx
index adebd57..a011837 100644
--- a/snikket/ChatMessage.hx
+++ b/snikket/ChatMessage.hx
@@ -191,6 +191,22 @@ class ChatMessage {
 		return type == MessageChannel || type == MessageChannelPrivate ? serverId : localId;
 	}
 
+	@:allow(snikket)
+	private function makeModerated(timestamp: String, moderatorId: Null<String>, reason: Null<String>) {
+		text = null;
+		attachments = [];
+		payloads = [];
+		versions = [];
+		final cleanedStub = clone();
+		final payload = new Stanza("retracted", { xmlns: "urn:xmpp:message-retract:1", stamp: timestamp });
+		if (reason != null) payload.textTag("reason", reason);
+		payload.tag("moderated", { by: moderatorId, xmlns: "urn:xmpp:message-moderate:1" }).up();
+		payloads.push(payload);
+		final head = clone();
+		head.timestamp = timestamp;
+		versions = [head, cleanedStub];
+	}
+
 	private function set_localId(localId:Null<String>) {
 		if(this.localId != null) {
 			throw new Exception("Message already has a localId set");
diff --git a/snikket/Client.hx b/snikket/Client.hx
index 17a74e1..c6d205f 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -190,6 +190,8 @@ class Client extends EventEmitter {
 						fetchMediaByHash([hash], [from]);
 					}
 					persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored, ReactionEvent));
+				case ModerateMessageStanza(action):
+					moderateMessage(action).then((stored) -> if (stored != null) notifyMessageHandlers(stored, CorrectionEvent));
 				default:
 					// ignore
 			}
@@ -900,6 +902,23 @@ class Client extends EventEmitter {
 		return chats.find((chat) -> chat.chatId == chatId);
 	}
 
+	@:allow(snikket)
+	private function moderateMessage(action: ModerationAction): Promise<Null<ChatMessage>> {
+		return new thenshim.Promise((resolve, reject) ->
+			persistence.getMessage(accountId(), action.chatId, action.moderateServerId, null, (moderateMessage) -> {
+				if (moderateMessage == null) return resolve(null);
+				for(attachment in moderateMessage.attachments) {
+					for(hash in attachment.hashes) {
+						persistence.removeMedia(hash.algorithm, hash.hash);
+					}
+				}
+				moderateMessage.makeModerated(action.timestamp, action.moderatorId, action.reason);
+				persistence.updateMessage(accountId(), moderateMessage);
+				resolve(moderateMessage);
+			})
+		);
+	}
+
 	@:allow(snikket)
 	private function getDirectChat(chatId:String, triggerIfNew:Bool = true):DirectChat {
 		for (chat in chats) {
@@ -1417,6 +1436,10 @@ class Client extends EventEmitter {
 						promises.push(new thenshim.Promise((resolve, reject) -> {
 							persistence.storeReaction(accountId(), update, (_) -> resolve(null));
 						}));
+					case ModerateMessageStanza(action):
+						promises.push(new thenshim.Promise((resolve, reject) -> {
+							moderateMessage(action).then((_) -> resolve(null));
+						}));
 					default:
 						// ignore
 				}
diff --git a/snikket/Message.hx b/snikket/Message.hx
index 4e24647..fe1a9ea 100644
--- a/snikket/Message.hx
+++ b/snikket/Message.hx
@@ -26,6 +26,7 @@ enum abstract MessageType(Int) {
 enum MessageStanza {
 	ErrorMessageStanza(stanza: Stanza);
 	ChatMessageStanza(message: ChatMessage);
+	ModerateMessageStanza(action: ModerationAction);
 	ReactionUpdateStanza(update: ReactionUpdate);
 	UnknownMessageStanza(stanza: Stanza);
 }
@@ -198,7 +199,30 @@ class Message {
 			Reflect.setField(msg, "localId", jmi.attr.get("id"));
 		}
 
-		if (msg.text == null && msg.attachments.length < 1) return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza));
+		final retract = stanza.getChild("replace", "urn:xmpp:message-retract:1");
+		final fasten = stanza.getChild("apply-to", "urn:xmpp:fasten:0");
+		final moderated = retract?.getChild("moderated", "urn:xmpp:message-retract:1") ?? fasten?.getChild("moderated", "urn:xmpp:message-moderate:0");
+		final moderateServerId = retract?.attr?.get("id") ?? fasten?.attr?.get("id");
+		if (moderated != null && moderateServerId != null && isGroupchat && msg.from != null && msg.from.isBare() && msg.from.asString() == msg.chatId()) {
+			final reason = retract?.getChildText("reason") ?? moderated?.getChildText("reason");
+			final by = moderated.attr.get("by");
+			// TODO: occupant id as well / instead of by?
+			return new Message(
+				msg.chatId(),
+				msg.senderId(),
+				msg.threadId,
+				ModerateMessageStanza(new ModerationAction(msg.chatId(), moderateServerId, timestamp, by, reason))
+			);
+		}
+
+		final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0");
+		final replaceId  = replace?.attr?.get("id");
+		if (replaceId != null) {
+			msg.versions = [msg.clone()];
+			Reflect.setField(msg, "localId", replaceId);
+		}
+
+		if (msg.text == null && msg.attachments.length < 1 && msg.versions.length < 1) return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza));
 
 		for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) {
 			msg.payloads.push(fallback);
@@ -271,13 +295,6 @@ class Message {
 			}
 		}
 
-		final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0");
-		final replaceId  = replace?.attr?.get("id");
-		if (replaceId != null) {
-			msg.versions = [msg.clone()];
-			Reflect.setField(msg, "localId", replaceId);
-		}
-
 		return new Message(msg.chatId(), msg.senderId(), msg.threadId, ChatMessageStanza(msg));
 	}
 }
diff --git a/snikket/ModerationAction.hx b/snikket/ModerationAction.hx
new file mode 100644
index 0000000..5c5e89f
--- /dev/null
+++ b/snikket/ModerationAction.hx
@@ -0,0 +1,17 @@
+package snikket;
+
+class ModerationAction {
+	public final chatId: String;
+	public final moderateServerId: String;
+	public final timestamp: String;
+	public final moderatorId: Null<String>;
+	public final reason: Null<String>;
+
+	public function new(chatId: String, moderateServerId: String, timestamp: String, moderatorId: Null<String>, reason: Null<String>) {
+		this.chatId = chatId;
+		this.moderateServerId = moderateServerId;
+		this.timestamp = timestamp;
+		this.moderatorId = moderatorId;
+		this.reason = reason;
+	}
+}
diff --git a/snikket/Persistence.hx b/snikket/Persistence.hx
index bd07317..f63ea00 100644
--- a/snikket/Persistence.hx
+++ b/snikket/Persistence.hx
@@ -16,12 +16,15 @@ interface Persistence {
 	public function getChatsUnreadDetails(accountId: String, chats: Array<Chat>, callback: (details:Array<{ chatId: String, message: ChatMessage, unreadCount: Int }>)->Void):Void;
 	public function storeReaction(accountId: String, update: ReactionUpdate, callback: (Null<ChatMessage>)->Void):Void;
 	public function storeMessage(accountId: String, message: ChatMessage, callback: (ChatMessage)->Void):Void;
+	public function updateMessage(accountId: String, message: ChatMessage):Void;
 	public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus, callback: (ChatMessage)->Void):Void;
+	public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>, callback: (Null<ChatMessage>)->Void):Void;
 	public function getMessagesBefore(accountId: String, chatId: String, beforeId: Null<String>, beforeTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
 	public function getMessagesAfter(accountId: String, chatId: String, afterId: Null<String>, afterTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
 	public function getMessagesAround(accountId: String, chatId: String, aroundId: Null<String>, aroundTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void;
 	public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (has:Bool)->Void):Void;
 	public function storeMedia(mime:String, bytes:BytesData, callback: ()->Void):Void;
+	public function removeMedia(hashAlgorithm:String, hash:BytesData):Void;
 	public function storeCaps(caps:Caps):Void;
 	public function getCaps(ver:String, callback: (Null<Caps>)->Void):Void;
 	public function storeLogin(login:String, clientId:String, displayName:String, token:Null<String>):Void;
diff --git a/snikket/persistence/Dummy.hx b/snikket/persistence/Dummy.hx
index bfb178b..17e8093 100644
--- a/snikket/persistence/Dummy.hx
+++ b/snikket/persistence/Dummy.hx
@@ -41,6 +41,15 @@ class Dummy implements Persistence {
 		callback(message);
 	}
 
+	@HaxeCBridge.noemit
+	public function updateMessage(accountId: String, message: ChatMessage) {
+	}
+
+	@HaxeCBridge.noemit
+	public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>, callback: (Null<ChatMessage>)->Void) {
+		callback(null);
+	}
+
 	@HaxeCBridge.noemit
 	public function getMessagesBefore(accountId: String, chatId: String, beforeId: Null<String>, beforeTime: Null<String>, callback: (Array<ChatMessage>)->Void) {
 		callback([]);
@@ -86,6 +95,10 @@ class Dummy implements Persistence {
 		callback();
 	}
 
+	@HaxeCBridge.noemit
+	public function removeMedia(hashAlgorithm:String, hash:BytesData) {
+	}
+
 	@HaxeCBridge.noemit
 	public function storeCaps(caps:Caps) { }
 
diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx
index ad8d5c9..24a4e1a 100644
--- a/snikket/persistence/Sqlite.hx
+++ b/snikket/persistence/Sqlite.hx
@@ -190,6 +190,35 @@ class Sqlite implements Persistence {
 		callback(message);
 	}
 
+	@HaxeCBridge.noemit
+	public function updateMessage(accountId: String, message: ChatMessage) {
+		storeMessage(accountId, message, (_)->{});
+	}
+
+
+	public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>, callback: (Null<ChatMessage>)->Void) {
+		final q = new StringBuf();
+		q.add("SELECT stanza FROM messages WHERE account_id=");
+		db.addValue(q, accountId);
+		q.add(" AND chat_id=");
+		db.addValue(q, chatId);
+		if (serverId != null) {
+			q.add(" AND mam_id=");
+			db.addValue(q, serverId);
+		} else if (localId != null) {
+			q.add(" AND stanza_id=");
+			db.addValue(q, localId);
+		}
+		q.add("LIMIT 1");
+		final result = db.request(q.toString());
+		final messages = [];
+		for (row in result) {
+			callback(ChatMessage.fromStanza(Stanza.parse(row.stanza), JID.parse(accountId))); // TODO
+			return;
+		}
+		callback(null);
+	}
+
 	private function getMessages(accountId: String, chatId: String, time: String, op: String) {
 		final q = new StringBuf();
 		q.add("SELECT stanza FROM messages WHERE account_id=");
@@ -294,13 +323,13 @@ class Sqlite implements Persistence {
 	}
 
 	@HaxeCBridge.noemit
-	public function getMediaUri(hashAlgorithm:String, hash:BytesData, callback: (Null<String>)->Void) {
+	public function getMediaPath(hashAlgorithm:String, hash:BytesData) {
 		if (hashAlgorithm == "sha-256") {
 			final path = blobpath + "/f" + Bytes.ofData(hash).toHex();
 			if (FileSystem.exists(path)) {
-				callback("file://" + FileSystem.absolutePath(path));
+				return FileSystem.absolutePath(path);
 			} else {
-				callback(null);
+				return null;
 			}
 		} else if (hashAlgorithm == "sha-1") {
 			final q = new StringBuf();
@@ -309,10 +338,9 @@ class Sqlite implements Persistence {
 			q.add(" LIMIT 1");
 			final result = db.request(q.toString());
 			for (row in result) {
-				getMediaUri("sha-256", row.sha256, callback);
-				return;
+				return getMediaPath("sha-256", row.sha256);
 			}
-			callback(null);
+			return null;
 		} else {
 			throw "Unknown hash algorithm: " + hashAlgorithm;
 		}
@@ -320,7 +348,13 @@ class Sqlite implements Persistence {
 
 	@HaxeCBridge.noemit
 	public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (Bool)->Void) {
-		getMediaUri(hashAlgorithm, hash, (uri) -> callback(uri != null));
+		callback(getMediaPath(hashAlgorithm, hash) != null);
+	}
+
+	@HaxeCBridge.noemit
+	public function removeMedia(hashAlgorithm:String, hash:BytesData) {
+		final path = getMediaPath(hashAlgorithm, hash);
+		if (path != null) FileSystem.deleteFile(path);
 	}
 
 	@HaxeCBridge.noemit
diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js
index ecef1b7..b8be273 100644
--- a/snikket/persistence/browser.js
+++ b/snikket/persistence/browser.js
@@ -419,6 +419,12 @@ const browser = (dbname, tokenize, stemmer) => {
 			});
 		},
 
+		updateMessage: function(account, message) {
+			const tx = db.transaction(["messages"], "readwrite");
+			const store = tx.objectStore("messages");
+			store.put(serializeMessage(account, message));
+		},
+
 		updateMessageStatus: function(account, localId, status, callback) {
 			const tx = db.transaction(["messages"], "readwrite");
 			const store = tx.objectStore("messages");
@@ -569,11 +575,27 @@ const browser = (dbname, tokenize, stemmer) => {
 
 		hasMedia: function(hashAlgorithm, hash, callback) {
 			(async () => {
-				const response = await this.getMediaResponse(hashAlgorithm, hash);
+				const response = await this.getMediaResponse(mkNiUrl(hashAlgorithm, hash));
 				return !!response;
 			})().then(callback);
 		},
 
+		removeMedia: function(hashAlgorithm, hash) {
+			(async () => {
+				var niUrl;
+				if (hashAlgorithm === "sha-256") {
+					niUrl = mkNiUrl(hashAlgorithm, hash);
+				} else {
+					const tx = db.transaction(["keyvaluepairs"], "readonly");
+					const store = tx.objectStore("keyvaluepairs");
+					niUrl = await promisifyRequest(store.get(mkNiUrl(hashAlgorithm, hash)));
+					if (!niUrl) return;
+				}
+
+				return await cache.delete(niUrl);
+			})();
+		},
+
 		storeMedia: function(mime, buffer, callback) {
 			(async function() {
 				const sha256 = await crypto.subtle.digest("SHA-256", buffer);