component AP gateway: handle XMPP attachments -> AP likes conversion:
convert `noticed` attachments coming from XMPP to suitable `Like` activities. Virtual node is created in cache when necessary, and virtual items published to virtual JID mapping AP accounts are cached too. rel 370
This commit is contained in:
parent
89f67ee50d
commit
d841a51a1a
3 changed files with 183 additions and 65 deletions
|
|
@ -70,6 +70,7 @@ from .constants import (
|
|||
TYPE_FOLLOWERS,
|
||||
TYPE_TOMBSTONE,
|
||||
TYPE_MENTION,
|
||||
TYPE_LIKE,
|
||||
NS_AP,
|
||||
NS_AP_PUBLIC,
|
||||
PUBLIC_TUPLE
|
||||
|
|
@ -90,9 +91,9 @@ PLUGIN_INFO = {
|
|||
C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
|
||||
C.PI_PROTOCOLS: [],
|
||||
C.PI_DEPENDENCIES: [
|
||||
"XEP-0060", "XEP-0084", "XEP-0106", "XEP-0277", "XEP-0292", "XEP-0329",
|
||||
"XEP-0372", "XEP-0424", "XEP-0465", "PUBSUB_CACHE", "TEXT_SYNTAXES", "IDENTITY",
|
||||
"XEP-0054"
|
||||
"XEP-0054", "XEP-0060", "XEP-0084", "XEP-0106", "XEP-0277", "XEP-0292",
|
||||
"XEP-0329", "XEP-0372", "XEP-0424", "XEP-0465", "PUBSUB_CACHE", "TEXT_SYNTAXES",
|
||||
"IDENTITY", "PUBSUB_ATTACHMENTS"
|
||||
],
|
||||
C.PI_RECOMMENDATIONS: [],
|
||||
C.PI_MAIN: "APGateway",
|
||||
|
|
@ -131,6 +132,7 @@ class APGateway:
|
|||
self._c = host.plugins["PUBSUB_CACHE"]
|
||||
self._t = host.plugins["TEXT_SYNTAXES"]
|
||||
self._i = host.plugins["IDENTITY"]
|
||||
self._pa = host.plugins["PUBSUB_ATTACHMENTS"]
|
||||
self._p.addManagedNode(
|
||||
"",
|
||||
items_cb=self._itemsReceived,
|
||||
|
|
@ -287,9 +289,17 @@ class APGateway:
|
|||
return
|
||||
|
||||
ap_account = self._e.unescape(recipient.user)
|
||||
await self.convertAndPostItems(
|
||||
client, ap_account, recipient, itemsEvent.nodeIdentifier, itemsEvent.items
|
||||
)
|
||||
|
||||
if self._pa.isAttachmentNode(itemsEvent.nodeIdentifier):
|
||||
await self.convertAndPostAttachments(
|
||||
client, ap_account, itemsEvent.sender, itemsEvent.nodeIdentifier,
|
||||
itemsEvent.items, publisher=itemsEvent.sender
|
||||
)
|
||||
else:
|
||||
await self.convertAndPostItems(
|
||||
client, ap_account, itemsEvent.sender, itemsEvent.nodeIdentifier,
|
||||
itemsEvent.items
|
||||
)
|
||||
|
||||
async def getVirtualClient(self, actor_id: str) -> SatXMPPEntity:
|
||||
"""Get client for this component with a specified jid
|
||||
|
|
@ -406,11 +416,14 @@ class APGateway:
|
|||
|
||||
if found_item is None:
|
||||
# the node is not in cache, we have to make a request to retrieve the item
|
||||
# if doesn't exist, getItems will raise a NotFound exception
|
||||
# If the node doesn't exist, getItems will raise a NotFound exception
|
||||
found_items, __ = await self._p.getItems(
|
||||
self.client, author_jid, node, item_ids=[item_id]
|
||||
)
|
||||
found_item = found_items[0]
|
||||
try:
|
||||
found_item = found_items[0]
|
||||
except KeyError:
|
||||
raise exceptions.NotFound("requested items can't be found")
|
||||
|
||||
mb_data = await self._m.item2mbdata(
|
||||
self.client, found_item, author_jid, node
|
||||
|
|
@ -834,6 +847,12 @@ class APGateway:
|
|||
"""Generate base data for an activity
|
||||
|
||||
@param activity: one of ACTIVITY_TYPES
|
||||
@param actor_id: AP actor ID of the sender
|
||||
@param object_: content of "object" field
|
||||
@param target: content of "target" field
|
||||
@param activity_id: ID to use for the activity
|
||||
if not set it will be automatically generated, but it is usually desirable to
|
||||
set the ID manually so it can be retrieved (e.g. for Undo)
|
||||
"""
|
||||
if activity not in ACTIVITY_TYPES:
|
||||
raise exceptions.InternalError(f"invalid activity: {activity!r}")
|
||||
|
|
@ -948,27 +967,41 @@ class APGateway:
|
|||
service: jid.JID,
|
||||
node: str,
|
||||
items: List[domish.Element],
|
||||
subscribe_comments_nodes: bool = False,
|
||||
subscribe_extra_nodes: bool = True,
|
||||
) -> None:
|
||||
"""Convert XMPP items to AP items and post them to actor inbox
|
||||
|
||||
@param ap_account: account of ActivityPub actor
|
||||
@param service: JID of the virtual pubsub service corresponding to the AP actor
|
||||
@param node: virtual node corresponding to the AP actor and items
|
||||
@param subscribe_comments_nodes: if True, comment nodes present in given items,
|
||||
they will be automatically subscribed
|
||||
@param ap_account: account of ActivityPub actor receiving the item
|
||||
@param service: JID of the (virtual) pubsub service where the item has been
|
||||
published
|
||||
@param node: (virtual) node corresponding where the item has been published
|
||||
@param subscribe_extra_nodes: if True, extra data nodes will be automatically
|
||||
subscribed, that is comment nodes if present and attachments nodes.
|
||||
"""
|
||||
actor_id = await self.getAPActorIdFromAccount(ap_account)
|
||||
inbox = await self.getAPInboxFromId(actor_id)
|
||||
for item in items:
|
||||
if item.name == "item":
|
||||
mb_data = await self._m.item2mbdata(client, item, service, node)
|
||||
if subscribe_comments_nodes:
|
||||
if subscribe_extra_nodes:
|
||||
# we subscribe automatically to comment nodes if any
|
||||
recipient_jid = self.getLocalJIDFromAccount(ap_account)
|
||||
recipient_client = self.client.getVirtualClient(recipient_jid)
|
||||
for comment_data in mb_data.get("comments", []):
|
||||
comment_service = jid.JID(comment_data["service"])
|
||||
comment_node = comment_data["node"]
|
||||
await self._p.subscribe(client, comment_service, comment_node)
|
||||
await self._p.subscribe(
|
||||
recipient_client, comment_service, comment_node
|
||||
)
|
||||
try:
|
||||
await self._pa.subscribe(
|
||||
recipient_client, service, node, mb_data["id"]
|
||||
)
|
||||
except exceptions.NotFound:
|
||||
log.debug(
|
||||
f"no attachment node found for item {mb_data['id']!r} on "
|
||||
f"{node!r} at {service}"
|
||||
)
|
||||
ap_item = await self.mbdata2APitem(client, mb_data)
|
||||
url_actor = ap_item["actor"]
|
||||
elif item.name == "retract":
|
||||
|
|
@ -978,12 +1011,110 @@ class APGateway:
|
|||
else:
|
||||
raise exceptions.InternalError(f"unexpected element: {item.toXml()}")
|
||||
resp = await self.signAndPost(inbox, url_actor, ap_item)
|
||||
if resp.code >= 300:
|
||||
text = await resp.text()
|
||||
|
||||
async def convertAndPostAttachments(
|
||||
self,
|
||||
client: SatXMPPEntity,
|
||||
ap_account: str,
|
||||
service: jid.JID,
|
||||
node: str,
|
||||
items: List[domish.Element],
|
||||
publisher: Optional[jid.JID]
|
||||
) -> None:
|
||||
"""Convert XMPP item attachments to AP activities and post them to actor inbox
|
||||
|
||||
@param ap_account: account of ActivityPub actor receiving the item
|
||||
@param service: JID of the (virtual) pubsub service where the item has been
|
||||
published
|
||||
@param node: (virtual) node corresponding where the item has been published
|
||||
@param subscribe_extra_nodes: if True, extra data nodes will be automatically
|
||||
subscribed, that is comment nodes if present and attachments nodes.
|
||||
"""
|
||||
if len(items) != 1:
|
||||
log.warning(
|
||||
"we should get exactly one attachment item for an entity, got "
|
||||
f"{len(items)})"
|
||||
)
|
||||
|
||||
actor_id = await self.getAPActorIdFromAccount(ap_account)
|
||||
inbox = await self.getAPInboxFromId(actor_id)
|
||||
|
||||
item_elt = items[0]
|
||||
if publisher is not None:
|
||||
item_elt["publisher"] = publisher.userhost()
|
||||
item_service, item_node, item_id = self._pa.attachmentNode2Item(node)
|
||||
item_account = await self.getAPAccountFromJidAndNode(item_service, item_node)
|
||||
if item_service.host == self.client.jid.userhost():
|
||||
# it's a virtual JID mapping to an external AP actor, we can use the
|
||||
# item_id directly
|
||||
item_url = item_id
|
||||
if not item_url.startswith("https:"):
|
||||
log.warning(
|
||||
f"unexpected return code while sending AP item: {resp.code}\n{text}\n"
|
||||
f"{pformat(ap_item)}"
|
||||
"item ID of external AP actor is not an https link, ignoring: "
|
||||
f"{item_id!r}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
item_url = self.buildAPURL(TYPE_ITEM, item_account, item_id)
|
||||
|
||||
old_attachment_pubsub_items = await self.host.memory.storage.searchPubsubItems({
|
||||
"profiles": [self.client.profile],
|
||||
"services": [service],
|
||||
"names": [item_elt["id"]]
|
||||
})
|
||||
if not old_attachment_pubsub_items:
|
||||
old_attachment = {}
|
||||
else:
|
||||
old_attachment_items = [i.data for i in old_attachment_pubsub_items]
|
||||
old_attachments = self._pa.items2attachmentData(client, old_attachment_items)
|
||||
try:
|
||||
old_attachment = old_attachments[0]
|
||||
except IndexError:
|
||||
# no known element was present in attachments
|
||||
old_attachment = {}
|
||||
sender_account = await self.getAPAccountFromJidAndNode(
|
||||
client.jid,
|
||||
None
|
||||
)
|
||||
sender_actor_id = self.buildAPURL(TYPE_ACTOR, sender_account)
|
||||
try:
|
||||
attachments = self._pa.items2attachmentData(client, [item_elt])[0]
|
||||
except IndexError:
|
||||
# no known element was present in attachments
|
||||
attachments = {}
|
||||
|
||||
if "noticed" in attachments:
|
||||
if not "noticed" in old_attachment:
|
||||
# new "noticed" attachment, we translate to "Like" activity
|
||||
activity_id = self.buildAPURL("like", item_account, item_id)
|
||||
like = self.createActivity(
|
||||
TYPE_LIKE, sender_actor_id, item_url, activity_id=activity_id
|
||||
)
|
||||
like["to"] = [NS_AP_PUBLIC]
|
||||
await self.signAndPost(inbox, sender_actor_id, like)
|
||||
else:
|
||||
if "noticed" in old_attachment:
|
||||
# "noticed" attachment has been removed, we undo the "Like" activity
|
||||
activity_id = self.buildAPURL("like", item_account, item_id)
|
||||
like = self.createActivity(
|
||||
TYPE_LIKE, sender_actor_id, item_url, activity_id=activity_id
|
||||
)
|
||||
like["to"] = [NS_AP_PUBLIC]
|
||||
undo = self.createActivity("Undo", sender_actor_id, like)
|
||||
await self.signAndPost(inbox, sender_actor_id, undo)
|
||||
|
||||
if service.user and service.host == self.client.jid.userhost():
|
||||
# the item is on a virtual service, we need to store it in cache
|
||||
log.debug("storing attachments item in cache")
|
||||
cached_node = await self.host.memory.storage.getPubsubNode(
|
||||
client, service, node, with_subscriptions=True, create=True
|
||||
)
|
||||
await self.host.memory.storage.cachePubsubItems(
|
||||
self.client,
|
||||
cached_node,
|
||||
[item_elt],
|
||||
[attachments]
|
||||
)
|
||||
|
||||
async def signAndPost(self, url: str, actor_id: str, doc: dict) -> TReqResponse:
|
||||
"""Sign a documentent and post it to AP server
|
||||
|
|
@ -1020,7 +1151,7 @@ class APGateway:
|
|||
body,
|
||||
headers=headers,
|
||||
)
|
||||
if resp.code >= 400:
|
||||
if resp.code >= 300:
|
||||
text = await resp.text()
|
||||
log.warning(f"POST request to {url} failed [{resp.code}]: {text}")
|
||||
elif self.verbose:
|
||||
|
|
@ -1694,7 +1825,7 @@ class APGateway:
|
|||
target_ap_account = await self.getAPAccountFromJidAndNode(
|
||||
service, node
|
||||
)
|
||||
if service.host == self.client.jid.userhost:
|
||||
if service.host == self.client.jid.userhost():
|
||||
# service is a proxy JID for AP account
|
||||
actor_data = await self.getAPActorDataFromAccount(target_ap_account)
|
||||
followers = actor_data.get("followers")
|
||||
|
|
@ -1758,8 +1889,6 @@ class APGateway:
|
|||
item_data = await self.mbdata2APitem(client, mess_data)
|
||||
url_actor = item_data["actor"]
|
||||
resp = await self.signAndPost(inbox_url, url_actor, item_data)
|
||||
if resp.code != 202:
|
||||
raise exceptions.NetworkError(f"unexpected return code: {resp.code}")
|
||||
|
||||
async def apDeleteItem(
|
||||
self,
|
||||
|
|
@ -1909,12 +2038,6 @@ class APGateway:
|
|||
from_jid.userhostJID(), None, fastened_elts.id, public=False
|
||||
)
|
||||
resp = await self.signAndPost(inbox, url_actor, ap_item)
|
||||
if resp.code >= 300:
|
||||
text = await resp.text()
|
||||
log.warning(
|
||||
f"unexpected return code while sending AP item: {resp.code}\n{text}\n"
|
||||
f"{pformat(ap_item)}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def _onReferenceReceived(
|
||||
|
|
@ -2003,12 +2126,6 @@ class APGateway:
|
|||
inbox = await self.getAPInboxFromId(actor_id)
|
||||
|
||||
resp = await self.signAndPost(inbox, ap_item["actor"], ap_item)
|
||||
if resp.code >= 300:
|
||||
text = await resp.text()
|
||||
log.warning(
|
||||
f"unexpected return code while sending AP item: {resp.code}\n{text}\n"
|
||||
f"{pformat(ap_item)}"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -2263,27 +2380,9 @@ class APGateway:
|
|||
parent_node, __ = await self.getCommentsNodes(item["id"], in_reply_to)
|
||||
node = parent_node or node
|
||||
cached_node = await self.host.memory.storage.getPubsubNode(
|
||||
client, service, node, with_subscriptions=True
|
||||
client, service, node, with_subscriptions=True, create=True,
|
||||
create_kwargs={"subscribed": True}
|
||||
)
|
||||
if cached_node is None:
|
||||
try:
|
||||
cached_node = await self.host.memory.storage.setPubsubNode(
|
||||
client,
|
||||
service,
|
||||
node,
|
||||
subscribed=True
|
||||
)
|
||||
except IntegrityError as e:
|
||||
if "unique" in str(e.orig).lower():
|
||||
# the node may already exist, if it has been created just after
|
||||
# getPubsubNode above
|
||||
log.debug("ignoring UNIQUE constraint error")
|
||||
cached_node = await self.host.memory.storage.getPubsubNode(
|
||||
client, service, node, with_subscriptions=True
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
else:
|
||||
# it is a root item (i.e. not a reply to an other item)
|
||||
cached_node = await self.host.memory.storage.getPubsubNode(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ TYPE_FOLLOWING = "following"
|
|||
TYPE_ITEM = "item"
|
||||
TYPE_TOMBSTONE = "Tombstone"
|
||||
TYPE_MENTION = "Mention"
|
||||
TYPE_LIKE = "Like"
|
||||
MEDIA_TYPE_AP = "application/activity+json"
|
||||
NS_AP = "https://www.w3.org/ns/activitystreams"
|
||||
NS_AP_PUBLIC = f"{NS_AP}#Public"
|
||||
|
|
|
|||
|
|
@ -119,9 +119,14 @@ class APPubsubService(rsm.PubSubService):
|
|||
)
|
||||
|
||||
client = self.apg.client.getVirtualClient(requestor)
|
||||
await self.apg.convertAndPostItems(
|
||||
client, ap_account, service, nodeIdentifier, items
|
||||
)
|
||||
if self.apg._pa.isAttachmentNode(nodeIdentifier):
|
||||
await self.apg.convertAndPostAttachments(
|
||||
client, ap_account, service, nodeIdentifier, items, publisher=requestor
|
||||
)
|
||||
else:
|
||||
await self.apg.convertAndPostItems(
|
||||
client, ap_account, service, nodeIdentifier, items
|
||||
)
|
||||
|
||||
async def apFollowing2Elt(self, ap_item: dict) -> domish.Element:
|
||||
"""Convert actor ID from following collection to XMPP item"""
|
||||
|
|
@ -291,6 +296,10 @@ class APPubsubService(rsm.PubSubService):
|
|||
log.warning(f"Invalid AP account used by {requestor}: {ap_account!r}")
|
||||
return [], None
|
||||
|
||||
# cached_node may be pre-filled with some nodes (e.g. attachments nodes),
|
||||
# otherwise it is filled when suitable
|
||||
cached_node = None
|
||||
client = self.apg.client
|
||||
kwargs = {}
|
||||
|
||||
if node == self.apg._pps.subscriptions_node:
|
||||
|
|
@ -315,6 +324,15 @@ class APPubsubService(rsm.PubSubService):
|
|||
self.apg.client, ap_account, itemIdentifiers
|
||||
)
|
||||
return [item_elt], None
|
||||
elif self.apg._pa.isAttachmentNode(node):
|
||||
use_cache = True
|
||||
# we check cache here because we emit an item-not-found error if the node is
|
||||
# not in cache, as we are not dealing with real AP items
|
||||
cached_node = await self.host.memory.storage.getPubsubNode(
|
||||
client, service, node
|
||||
)
|
||||
if cached_node is None:
|
||||
raise error.StanzaError("item-not-found")
|
||||
else:
|
||||
if not node.startswith(self.apg._m.namespace):
|
||||
raise error.StanzaError(
|
||||
|
|
@ -326,11 +344,11 @@ class APPubsubService(rsm.PubSubService):
|
|||
parser = self.apg.apItem2Elt
|
||||
use_cache = True
|
||||
|
||||
client = self.apg.client
|
||||
if use_cache:
|
||||
cached_node = await self.host.memory.storage.getPubsubNode(
|
||||
client, service, node
|
||||
)
|
||||
if cached_node is None:
|
||||
cached_node = await self.host.memory.storage.getPubsubNode(
|
||||
client, service, node
|
||||
)
|
||||
# TODO: check if node is synchronised
|
||||
if cached_node is not None:
|
||||
# the node is cached, we return items from cache
|
||||
|
|
@ -467,7 +485,7 @@ class APPubsubService(rsm.PubSubService):
|
|||
data = self.apg.createActivity("Follow", req_actor_id, recip_actor_id)
|
||||
|
||||
resp = await self.apg.signAndPost(inbox, req_actor_id, data)
|
||||
if resp.code >= 400:
|
||||
if resp.code >= 300:
|
||||
text = await resp.text()
|
||||
raise error.StanzaError("service-unavailable", text=text)
|
||||
return pubsub.Subscription(nodeIdentifier, requestor, "subscribed")
|
||||
|
|
@ -488,7 +506,7 @@ class APPubsubService(rsm.PubSubService):
|
|||
)
|
||||
|
||||
resp = await self.apg.signAndPost(inbox, req_actor_id, data)
|
||||
if resp.code >= 400:
|
||||
if resp.code >= 300:
|
||||
text = await resp.text()
|
||||
raise error.StanzaError("service-unavailable", text=text)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue