From 2df2bc28d08dee5cc0864fd2e874a9e43035b3ae Mon Sep 17 00:00:00 2001 From: Valentin Lorentz Date: Wed, 20 Jul 2022 17:53:00 +0200 Subject: [PATCH] Fediverse: Add support for videos --- plugins/Fediverse/plugin.py | 35 +++++-- plugins/Fediverse/test.py | 19 ++++ plugins/Fediverse/test_data.py | 177 +++++++++++++++++++++++++++++++++ plugins/Fediverse/utils.py | 63 ++++++++++++ 4 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 plugins/Fediverse/utils.py diff --git a/plugins/Fediverse/plugin.py b/plugins/Fediverse/plugin.py index 0dea17aee..134569f31 100644 --- a/plugins/Fediverse/plugin.py +++ b/plugins/Fediverse/plugin.py @@ -38,6 +38,7 @@ from supybot.commands import urlSnarfer, wrap from supybot.i18n import PluginInternationalization from . import activitypub as ap +from .utils import parse_xsd_duration importlib.reload(ap) @@ -222,18 +223,29 @@ class Fediverse(callbacks.PluginRegexp): name = actor.get("name", username) return "\x02%s\x02 (@%s@%s)" % (name, username, hostname) + def _format_author(self, irc, author): + if isinstance(author, str): + # it's an URL + try: + author = self._get_actor(irc, author) + except ap.ActivityPubError as e: + return _("") % str(e) + else: + return self._format_actor_fullname(author) + elif isinstance(author, dict): + if author.get("id"): + return self._format_author(irc, author["id"]) + elif isinstance(author, list): + return format("%L", [self._format_author(irc, item) for item in author]) + else: + return "" + def _format_status(self, irc, msg, status): if status["type"] == "Create": return self._format_status(irc, msg, status["object"]) elif status["type"] == "Note": - author_url = status["attributedTo"] - try: - author = self._get_actor(irc, author_url) - except ap.ActivityPubError as e: - author_fullname = _("") % str(e) - else: - author_fullname = self._format_actor_fullname(author) cw = status.get("summary") + author_fullname = self._format_author(irc, status.get("attributedTo")) if cw: if self.registryValue( "format.statuses.showContentWithCW", @@ -275,6 +287,15 @@ class Fediverse(callbacks.PluginRegexp): return self._format_status(irc, msg, status) except ap.ActivityPubProtocolError as e: return "" % e.args[0] + elif status["type"] == "Video": + author_fullname = self._format_author(irc, status.get("attributedTo")) + return format( + _("\x02%s\x02 (%T) by %s: %s"), + status["name"], + abs(parse_xsd_duration(status["duration"]).total_seconds()), + author_fullname, + status["content"], + ) else: assert False, "Unknown status type %s: %r" % ( status["type"], diff --git a/plugins/Fediverse/test.py b/plugins/Fediverse/test.py index 87b956cca..e28b5facc 100644 --- a/plugins/Fediverse/test.py +++ b/plugins/Fediverse/test.py @@ -60,6 +60,10 @@ from .test_data import ( BOOSTED_DATA, BOOSTED_ACTOR_URL, BOOSTED_ACTOR_DATA, + PEERTUBE_VIDEO_URL, + PEERTUBE_VIDEO_DATA, + PEERTUBE_ACTOR_URL, + PEERTUBE_ACTOR_DATA, ) @@ -430,6 +434,21 @@ class NetworklessFediverseTestCase(BaseFediverseTestCase): + "", ) + def testVideo(self): + expected_requests = [ + (PEERTUBE_VIDEO_URL, PEERTUBE_VIDEO_DATA), + (PEERTUBE_ACTOR_URL, PEERTUBE_ACTOR_DATA), + (ACTOR_URL, ACTOR_DATA), + ] + + with self.mockRequests(expected_requests): + self.assertResponse( + "status https://example.org/w/gABde9e210FGHre", + "\x02name of video\x02 (1 hour, 26 minutes, and 0 seconds) " + "by \x02chocobozzz\x02 (@chocobozzz@peertube.cpy.re) " + "and \x02someuser\x02 (@someuser@example.org): description of video" + ) + def testStatusUrlSnarferDisabled(self): with self.mockWebfingerSupport("not called"), self.mockRequests([]): self.assertSnarfNoResponse( diff --git a/plugins/Fediverse/test_data.py b/plugins/Fediverse/test_data.py index c035e63ee..68b6cfdc4 100644 --- a/plugins/Fediverse/test_data.py +++ b/plugins/Fediverse/test_data.py @@ -384,3 +384,180 @@ BOOSTED_ACTOR_VALUE = { "endpoints": {"sharedInbox": "https://example.net/inbox"}, } BOOSTED_ACTOR_DATA = json.dumps(BOOSTED_ACTOR_VALUE).encode() + +PEERTUBE_ACTOR_VALUE = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + }, + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "playlists": { + "@id": "pt:playlists", + "@type": "@id" + } + } + ], + "type": "Person", + "id": "https://peertube.cpy.re/accounts/chocobozzz", + "following": "https://peertube.cpy.re/accounts/chocobozzz/following", + "followers": "https://peertube.cpy.re/accounts/chocobozzz/followers", + "playlists": "https://peertube.cpy.re/accounts/chocobozzz/playlists", + "inbox": "https://peertube.cpy.re/accounts/chocobozzz/inbox", + "outbox": "https://peertube.cpy.re/accounts/chocobozzz/outbox", + "preferredUsername": "chocobozzz", + "url": "https://peertube.cpy.re/accounts/chocobozzz", + "name": "chocobozzz", + "published": "2017-11-28T08:48:24.271Z", + "summary": None +} +PEERTUBE_ACTOR_DATA = json.dumps(PEERTUBE_ACTOR_VALUE).encode() +PEERTUBE_ACTOR_URL = "https://peertube.cpy.re/accounts/chocobozzz" + + +PEERTUBE_VIDEO_VALUE = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + }, + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "Hashtag": "as:Hashtag", + "uuid": "sc:identifier", + "category": "sc:category", + "licence": "sc:license", + "subtitleLanguage": "sc:subtitleLanguage", + "sensitive": "as:sensitive", + "language": "sc:inLanguage", + "icons": "as:icon", + "isLiveBroadcast": "sc:isLiveBroadcast", + "liveSaveReplay": { + "@type": "sc:Boolean", + "@id": "pt:liveSaveReplay" + }, + "permanentLive": { + "@type": "sc:Boolean", + "@id": "pt:permanentLive" + }, + "latencyMode": { + "@type": "sc:Number", + "@id": "pt:latencyMode" + }, + "Infohash": "pt:Infohash", + "originallyPublishedAt": "sc:datePublished", + "views": { + "@type": "sc:Number", + "@id": "pt:views" + }, + "state": { + "@type": "sc:Number", + "@id": "pt:state" + }, + "size": { + "@type": "sc:Number", + "@id": "pt:size" + }, + "fps": { + "@type": "sc:Number", + "@id": "pt:fps" + }, + "commentsEnabled": { + "@type": "sc:Boolean", + "@id": "pt:commentsEnabled" + }, + "downloadEnabled": { + "@type": "sc:Boolean", + "@id": "pt:downloadEnabled" + }, + "waitTranscoding": { + "@type": "sc:Boolean", + "@id": "pt:waitTranscoding" + }, + "support": { + "@type": "sc:Text", + "@id": "pt:support" + }, + "likes": { + "@id": "as:likes", + "@type": "@id" + }, + "dislikes": { + "@id": "as:dislikes", + "@type": "@id" + }, + "shares": { + "@id": "as:shares", + "@type": "@id" + }, + "comments": { + "@id": "as:comments", + "@type": "@id" + } + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Video", + "name": "name of video", + "duration": "PT5160S", + "tag": [ + { + "type": "Hashtag", + "name": "vostfr" + } + ], + "category": { + "identifier": "2", + "name": "Films" + }, + "licence": { + "identifier": "4", + "name": "Attribution - Non Commercial" + }, + "language": { + "identifier": "en", + "name": "English" + }, + "views": 13718, + "sensitive": False, + "waitTranscoding": False, + "state": 1, + "commentsEnabled": True, + "downloadEnabled": True, + "published": "2017-10-23T07:54:38.155Z", + "originallyPublishedAt": None, + "updated": "2022-07-13T07:03:12.373Z", + "mediaType": "text/markdown", + "content": "description of video", + "support": None, + "subtitleLanguage": [], + "icon": [ + # redacted + ], + "url": [ + # redacted + ], + "attributedTo": [ + { + "type": "Person", + "id": PEERTUBE_ACTOR_URL + }, + { + "type": "Group", + "id": ACTOR_URL, + } + ], + "isLiveBroadcast": False, + "liveSaveReplay": None, + "permanentLive": None, + "latencyMode": None, +} +PEERTUBE_VIDEO_DATA = json.dumps(PEERTUBE_VIDEO_VALUE).encode() +PEERTUBE_VIDEO_URL = "https://example.org/w/gABde9e210FGHre" diff --git a/plugins/Fediverse/utils.py b/plugins/Fediverse/utils.py new file mode 100644 index 000000000..c55b1c42a --- /dev/null +++ b/plugins/Fediverse/utils.py @@ -0,0 +1,63 @@ +### +# Copyright (c) 2022, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +### + +import re +import datetime + +# Credits for the regexp and function: https://stackoverflow.com/a/2765366/539465 +_XSD_DURATION_RE = re.compile( + "(?P-?)P" + "(?:(?P\d+)Y)?" + "(?:(?P\d+)M)?" + "(?:(?P\d+)D)?" + "(?:T(?:(?P\d+)H)?(?:(?P\d+)M)?(?:(?P\d+)S)?)?" +) + + +def parse_xsd_duration(s): + """Parses this format to a timedelta: + https://www.w3.org/TR/xmlschema11-2/#duration""" + # Fetch the match groups with default value of 0 (not None) + duration = _XSD_DURATION_RE.match(s).groupdict(0) + + # Create the timedelta object from extracted groups + delta = datetime.timedelta( + days=int(duration["days"]) + + (int(duration["months"]) * 30) + + (int(duration["years"]) * 365), + hours=int(duration["hours"]), + minutes=int(duration["minutes"]), + seconds=int(duration["seconds"]), + ) + + if duration["sign"] == "-": + delta *= -1 + + return delta