# HG changeset patch # User Goffi # Date 1661958423 -7200 # Node ID 1f88ca90c3dec1512c00a99957346c70ff5523ed # Parent 18ff4f75f0e61aff1dbd15a02f26c56d285a423e plugin XEP-0444: Message Reactions implementation: rel 371 diff -r 18ff4f75f0e6 -r 1f88ca90c3de sat/plugins/plugin_xep_0444.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0444.py Wed Aug 31 17:07:03 2022 +0200 @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +# Libervia plugin for XEP-0444 +# Copyright (C) 2009-2021 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 List, Iterable +from copy import deepcopy + +from twisted.words.protocols.jabber import jid, xmlstream +from twisted.words.xish import domish +from twisted.internet import defer +from wokkel import disco, iwokkel +from zope.interface import implementer + +from sat.core.constants import Const as C +from sat.core.i18n import _ +from sat.core.log import getLogger +from sat.core import exceptions +from sat.core.core_types import SatXMPPEntity +from sat.memory.sqla_mapping import History + +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-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: + + def __init__(self, host): + log.info(_("Message Reactions initialization")) + host.registerNamespace("reactions", NS_REACTIONS) + self.host = host + self._h = host.plugins["XEP-0334"] + host.bridge.addMethod( + "messageReactionsSet", + ".plugin", + in_sign="ssas", + out_sign="", + method=self._reactionsSet, + async_=True, + ) + host.trigger.add("messageReceived", self._messageReceivedTrigger) + + def getHandler(self, client): + return XEP_0444_Handler() + + async def _messageReceivedTrigger( + self, + client: SatXMPPEntity, + message_elt: domish.Element, + post_treat: defer.Deferred + ) -> bool: + return True + + def _reactionsSet(self, message_id: str, profile: str, reactions: List[str]) -> None: + client = self.host.getClient(profile) + return defer.ensureDeferred( + self.setReactions(client, message_id) + ) + + def sendReactions( + self, + client: SatXMPPEntity, + dest_jid: jid.JID, + message_id: str, + reactions: Iterable[str] + ) -> None: + """Send the stanza containing the reactions + + @param dest_jid: recipient of the reaction + @param message_id: either or message's 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() + 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.addHintElements(message_elt, [self._h.HINT_STORE]) + client.send(message_elt) + + async def addReactionsToHistory( + self, + history: History, + from_jid: jid.JID, + reactions: Iterable[str] + ) -> None: + """Update History instance with given reactions + + @param history: storage History instance + will be updated in DB + "summary" field of history.extra["reactions"] will also be updated + @param from_jid: author of the reactions + @param reactions: list of reactions + """ + history.extra = deepcopy(history.extra) if history.extra else {} + h_reactions = history.extra.setdefault("reactions", {}) + # reactions mapped by originating JID + by_jid = h_reactions.setdefault("by_jid", {}) + # reactions are sorted to in summary to keep a consistent order + h_reactions["by_jid"][from_jid.userhost()] = sorted(list(set(reactions))) + h_reactions["summary"] = sorted(list(set().union(*by_jid.values()))) + await self.host.memory.storage.session_add(history) + + async def setReactions( + self, + client: SatXMPPEntity, + message_id: str, + reactions: Iterable[str] + ) -> 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 + """ + if not message_id: + raise ValueError("message_id can't be empty") + history = await self.host.memory.storage.get( + client, History, History.uid, message_id, + joined_loads=[History.messages, History.subjects] + ) + if history is None: + raise exceptions.NotFound( + f"message to retract not found in database ({message_id})" + ) + mess_id = history.origin_id or history.stanza_id + if not mess_id: + raise exceptions.DataError( + "target message has neither origin-id nor message-id, we can't send a " + "reaction" + ) + await self.addReactionsToHistory(history, client.jid, reactions) + self.sendReactions(client, history.dest_jid, mess_id, reactions) + + +@implementer(iwokkel.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 []