#!/usr/bin/env python3 # Libervia plugin for XEP-0444 # Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Iterable, List from twisted.internet import defer from twisted.words.protocols.jabber import jid, xmlstream from twisted.words.xish import domish from tx_xmpp import disco, itx_xmpp from zope.interface import implementer import emoji from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger from libervia.backend.memory.sqla_mapping import History from libervia.backend.models.core import MessageReactionData from libervia.backend.tools.utils import aio from libervia.backend.memory.sqla import select from sqlalchemy.orm.attributes import flag_modified log = getLogger(__name__) PLUGIN_INFO = { C.PI_NAME: "Message Reactions", C.PI_IMPORT_NAME: "XEP-0444", C.PI_TYPE: C.PLUG_TYPE_XEP, C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0444"], C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0334"], C.PI_MAIN: "XEP_0444", C.PI_HANDLER: "yes", C.PI_DESCRIPTION: _("""Message Reactions implementation"""), } NS_REACTIONS = "urn:xmpp:reactions:0" class XEP_0444: # TODO: implement and use occupant-ID (XEP-0421), and check sender (see # https://xmpp.org/extensions/xep-0444.html#acceptable-reactions). def __init__(self, host): log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization") host.register_namespace("reactions", NS_REACTIONS) self.host = host self._m = host.plugins["XEP-0045"] self._h = host.plugins["XEP-0334"] host.bridge.add_method( "message_reactions_set", ".plugin", in_sign="sasss", out_sign="", method=self._reactions_set, async_=True, ) host.trigger.add("message_received", self._message_received_trigger) def get_handler(self, client): return XEP_0444_Handler() async def _message_received_trigger( self, client: SatXMPPEntity, message_elt: domish.Element, post_treat: defer.Deferred, ) -> bool: reactions_elt = next(message_elt.elements(NS_REACTIONS, "reactions"), None) if reactions_elt is not None: reaction_id = reactions_elt.getAttribute("id") message_type = message_elt.getAttribute("type") history = await self.host.memory.storage.get_history_from_xmpp_id( client, reaction_id, message_type ) if history is None: log.warning( f"Can't find matching message for reaction: {reactions_elt.toXml()}" ) else: if not reaction_id: log.warning(f"Invalid reaction: {reactions_elt.toXml()}") return False from_jid = jid.JID(message_elt["from"]) reactions = set() for reaction_elt in reactions_elt.elements("reaction"): reaction = str(reaction_elt) if ( not emoji.purely_emoji(reaction) or emoji.emoji_count(reaction) != 1 ): log.warning(f"Ignoring invalid reaction: {reaction_elt.toXml()}.") continue reactions.add(reaction) await self.update_history_reactions(client, history, from_jid, reactions) return False return True def _reactions_set( self, message_id: str, reactions: List[str], update_type: str = "replace", profile: str = C.PROF_KEY_NONE, ) -> defer.Deferred[None]: client = self.host.get_client(profile) return defer.ensureDeferred( self.set_reactions(client, message_id, reactions, update_type) ) async def get_history_from_uid( self, client: SatXMPPEntity, message_id: str ) -> History: """Retrieve the chat history associated with a given message ID. @param message_id: The Libervia specific identifier of the message @return: An instance of History containing the chat history, messages, and subjects related to the specified message ID. @raises exceptions.NotFound: The history corresponding to the given message ID is not found in the database. """ history = await self.host.memory.storage.get( client, History, History.uid, message_id, ) if history is None: raise exceptions.NotFound( f"message to retract not found in database ({message_id})" ) return history def send_reactions( self, client: SatXMPPEntity, dest_jid: jid.JID, message_type: str, message_id: str, reactions: Iterable[str], ) -> None: """Send the stanza containing the reactions @param dest_jid: recipient of the reaction @param message_id: either origin ID or stanza ID see https://xmpp.org/extensions/xep-0444.html#business-id """ message_elt = domish.Element((None, "message")) message_elt["from"] = client.jid.full() message_elt["to"] = dest_jid.full() message_elt["type"] = message_type reactions_elt = message_elt.addElement((NS_REACTIONS, "reactions")) reactions_elt["id"] = message_id for r in set(reactions): reactions_elt.addElement("reaction", content=r) self._h.add_hint_elements(message_elt, [self._h.HINT_STORE]) client.send(message_elt) def convert_to_replace_update( self, current_reactions: set[str], reactions_new: Iterable[str], update_type: str, ) -> set[str]: """Convert the given update to a replace update. @param current_reactions: reaction of reacting JID before update @param reactions_new: New reactions to be updated. @param update_type: Original type of update (add, remove, toggle, replace). @return: reactions for a replace operation. @raise ValueError: invalid ``update_type``. """ new_reactions_set = set(reactions_new) if update_type == "replace": return new_reactions_set if update_type == "add": replace_reactions = current_reactions | new_reactions_set elif update_type == "remove": replace_reactions = current_reactions - new_reactions_set elif update_type == "toggle": replace_reactions = current_reactions ^ new_reactions_set else: raise ValueError(f"Invalid update type: {update_type!r}") return replace_reactions async def update_history_reactions( self, client: SatXMPPEntity, history: History, reacting_jid: jid.JID, reactions_new: Iterable[str], update_type: str = "replace", store: bool = True, ) -> set[str]: """Update reactions in History instance and optionally store and signal it. @param history: storage History instance to be updated @param reacting_jid: author of the reactions @param reactions_new: new reactions to update @param update_type: Original type of update (add, remove, toggle, replace). @param store: if True, update history in storage, and send a `message_update` signal with the new reactions. @return: set of reactions for this JID for a "replace" update """ # FIXME: handle race conditions if history.type == C.MESS_TYPE_GROUPCHAT: entity_jid_s = reacting_jid.full() else: entity_jid_s = reacting_jid.userhost() if history.extra is None: history.extra = {} extra = history.extra reactions = extra.get("reactions", {}) current_reactions = { reaction for reaction, jids in reactions.items() if entity_jid_s in jids } reactions_replace_set = self.convert_to_replace_update( current_reactions, reactions_new, update_type ) for reaction in current_reactions - reactions_replace_set: reaction_jids = reactions[reaction] reaction_jids.remove(entity_jid_s) if not reaction_jids: del reactions[reaction] for reaction in reactions_replace_set - current_reactions: reactions.setdefault(reaction, []).append(entity_jid_s) # we want to have a constant order in reactions extra["reactions"] = dict(sorted(reactions.items())) if store: # FIXME: this is not a clean way to flag "extra" as modified, but a deepcopy # is for some reason not working here. flag_modified(history, "extra") await self.host.memory.storage.add(history) # we send the signal for frontends update data = MessageReactionData(reactions=extra["reactions"]) self.host.bridge.message_update( history.uid, C.MESS_UPDATE_REACTION, data.model_dump_json(), client.profile, ) return reactions_replace_set async def set_reactions( self, client: SatXMPPEntity, message_id: str, reactions: Iterable[str], update_type: str = "replace", ) -> None: """Set and replace reactions to a message @param message_id: internal ID of the message @param rections: lsit of emojis to used to react to the message use empty list to remove all reactions @param update_type: how to use the reaction to make the update, can be "replace", "add", "remove" or "toggle". """ if not message_id: raise ValueError("message_id can't be empty") history = await self.get_history_from_uid(client, message_id) if history.type == C.MESS_TYPE_GROUPCHAT: mess_id = history.stanza_id else: mess_id = history.origin_id if mess_id is None: raise exceptions.DataError( "target message has neither origin-id nor message-id, we can't send a " "reaction" ) if history.source == client.jid.userhost(): dest_jid = history.dest_jid else: dest_jid = history.source_jid if history.type == C.MESS_TYPE_GROUPCHAT: # the reaction is for the chat room itself dest_jid = dest_jid.userhostJID() reacting_jid = self._m.get_room_user_jid(client, dest_jid) # we don't store reactions for MUC, at this will be done when we receive back # the reaction store = False else: # we use bare JIDs for one2one message, except if the recipient is from a MUC if self._m.is_room(client, dest_jid): # this is a private message in a room, we need to use the MUC JID reacting_jid = self._m.get_room_user_jid(client, dest_jid.userhostJID()) else: # this is a classic one2one message, we need the bare JID reacting_jid = client.jid.userhostJID() dest_jid = dest_jid.userhostJID() store = True reaction_replace_set = await self.update_history_reactions( client, history, reacting_jid, reactions, update_type, store ) self.send_reactions(client, dest_jid, history.type, mess_id, reaction_replace_set) @implementer(itx_xmpp.IDisco) class XEP_0444_Handler(xmlstream.XMPPHandler): def getDiscoInfo(self, requestor, service, nodeIdentifier=""): return [disco.DiscoFeature(NS_REACTIONS)] def getDiscoItems(self, requestor, service, nodeIdentifier=""): return []