From d7db3c477d0c6596aa5ab4e1f4c96c444afa2e00 Mon Sep 17 00:00:00 2001 From: Valcano Bacon Date: Sun, 22 Jan 2023 10:44:10 -0800 Subject: [PATCH 1/6] normalized channel names --- src/irc/__init__.py | 8 ++++++-- tests/test_irc.py | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/irc/__init__.py b/src/irc/__init__.py index 4a6f426..0066854 100644 --- a/src/irc/__init__.py +++ b/src/irc/__init__.py @@ -72,7 +72,7 @@ def cli( channel_map = defaultdict(lambda: defaultdict(set)) for channel, channel_map_type, value in irc_channel_map: channel_map[channel_map_type][value.lower().strip()].add( - channel.lower().strip() + _normalize_channel_name(channel) ) logging.debug(channel_map) @@ -192,7 +192,7 @@ async def subscribe_invoices(): else set() ) # Always post to all channels in irc_channel - channels.update(irc_channel) + channels.update(_normalize_channel_name(x) for x in irc_channel) for channel in channels: bot.send("PRIVMSG", target=channel, message=chunk) @@ -233,6 +233,10 @@ def _sanitize(message): return message +def _normalize_channel_name(channel_name: str) -> str: + return f"#{channel_name.lower().strip().strip('#')}" + + def _new_message(data, value, numerology_func=number_to_numerology): amount = f"\x02\u200b{value}\x02 sats" diff --git a/tests/test_irc.py b/tests/test_irc.py index 7447551..ed3cd24 100644 --- a/tests/test_irc.py +++ b/tests/test_irc.py @@ -1,6 +1,6 @@ from unittest.mock import sentinel -from src.irc import _chunks, _get, _new_message +from src.irc import _chunks, _get, _new_message, _normalize_channel_name def test_get(): @@ -69,3 +69,10 @@ def test_long_message_chunking(): "tuvwxyz0123456789 abcdefghijklmnOpqrstuvwxyz0123456789 abcdefghijklmnoPqrstuvwxyz0123456789 abcdefghijklmnopQrstuvwxyz0123456789 abcdefghijklmnopqRstuvwxyz0123456789 abcdefghijklmnopqrStuvwxyz0123456789 abcdefghijklmnopqrsTuvwxyz0123456789 abcdefghij", "klmnopqrstUvwxyz0123456789 abcdefghijklmnopqrstuVwxyz0123456789 abcdefghijklmnopqrstuvWxyz0123456789 abcdefghijklmnopqrstuvwXyz0123456789 abcdefghijklmnopqrstuvwxYz0123456789 abcdefghijklmnopqrstuvwxyZ0123456789", ] + + +def test_normalize_channel_name(): + assert _normalize_channel_name("BowlAfterBowl") == "#bowlafterbowl" + assert _normalize_channel_name("#BowlAfterBowl") == "#bowlafterbowl" + assert _normalize_channel_name(" bowlafterbowl ") == "#bowlafterbowl" + assert _normalize_channel_name(" #BOWLAFTERBOWL ") == "#bowlafterbowl" From 1dc43dda26c844b7f00b5d8c84063078b9dddd26 Mon Sep 17 00:00:00 2001 From: Valcano Bacon Date: Mon, 29 May 2023 23:45:40 -0700 Subject: [PATCH 2/6] implemented cross app comments --- .vscode/settings.json | 1 + setup.py | 3 + src/mastodon/__init__.py | 177 ++++++++++++++++++++++++++++++------ src/podcast_index.py | 66 ++++++++++++++ tests/test_podcast_index.py | 85 +++++++++++++++++ 5 files changed, 303 insertions(+), 29 deletions(-) create mode 100644 src/podcast_index.py create mode 100644 tests/test_podcast_index.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 8684a9e..d4ad3f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "atoot", + "beautifulsoup", "bech", "boostirc", "boostodon", diff --git a/setup.py b/setup.py index 709e453..78a59b4 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,9 @@ ], "mastodon": [ "atoot @ git+https://git@github.com/valcanobacon/atoot@1.0.2#egg=atoot", + "beautifulsoup4<5,>=4.12.2", + "requests<3,>=2.31.0", + "lxml<5,>=4.9.0", ], "matrix": [ "matrix-nio<1,>=0.19.0", diff --git a/src/mastodon/__init__.py b/src/mastodon/__init__.py index 56c0483..973c137 100644 --- a/src/mastodon/__init__.py +++ b/src/mastodon/__init__.py @@ -4,13 +4,19 @@ import functools import json import logging +from urllib.parse import urlparse import atoot import click +import requests +from bs4 import BeautifulSoup from lndgrpc import AsyncLNDClient from lndgrpc.aio.async_client import ln from ..numerology import number_to_numerology +from ..podcast_index import PodcastIndex + +logging.getLogger().setLevel(logging.INFO) def async_cmd(func): @@ -28,6 +34,8 @@ def wrapper(*args, **kwargs): @click.option("--lnd-tlscert", type=click.Path(exists=True), default="tls.cert") @click.option("--mastodon-instance") @click.option("--mastodon-access-token") +@click.option("--podcast-index-api-key") +@click.option("--podcast-index-api-secret") @click.option("--minimum-donation", type=int) @click.option("--allowed-name", multiple=True) @click.pass_context @@ -40,11 +48,19 @@ async def cli( lnd_tlscert, mastodon_instance, mastodon_access_token, + podcast_index_api_key, + podcast_index_api_secret, minimum_donation, allowed_name, ): ctx.ensure_object(dict) + podcast_index = PodcastIndex( + user_agent="BoostBots", + api_key=podcast_index_api_key, + api_secret=podcast_index_api_secret, + ) + mastodon = await atoot.MastodonAPI.create( mastodon_instance, access_token=mastodon_access_token ) @@ -87,42 +103,145 @@ async def cli( logging.debug("Donation too low, skipping", data) continue - sender = data.get("sender_name", "Anonymous") - - numerology = number_to_numerology(value) - - message = "" - if "podcast" in data and data["podcast"]: - message += data["podcast"] - if "episode" in data and data["episode"]: - message += f" {data['episode']}" - if "ts" in data and data["ts"]: - message += "@ {}".format( - datetime.timedelta(seconds=int(data["ts"])) - ) - message += "\n\n" - message += f"{numerology} {sender} boosted {value} sats" - message += "\n\n" - if "message" in data and data["message"]: - message += f"\"{data['message'].strip()}\"" - message += "\n\n" - message += "via {}".format(data.get("app_name", "Unknown")) - if "url" in data and data["url"] or "feedID" in data and data["feedID"]: - message += "\n\n" - if "url" in data and data["url"]: - message += "{}\n".format(data["url"]) - if "feedID" in data and data["feedID"]: - message += "https://podcastindex.org/podcast/{}\n".format( - data["feedID"] - ) + message = main_message(data, value) logging.debug(message) - await mastodon.create_status(status=message) + await mastodon.create_status( + status=message, + in_reply_to_id=None, + # bug work around to reset value + params={}, + ) + + await send_to_social_interact(mastodon, podcast_index, data, value) except: logging.exception("error") +async def send_to_social_interact(mastodon, podcast_index, data, value): + message = reply_message(data, value) + + feed_url = data.get("url") + if not feed_url: + feed_url = get_feed_url_from_podcast_index(podcast_index, data) + if not feed_url: + return + + logging.debug(feed_url) + + soup = get_feed(feed_url) + if not soup: + return + + items = soup.find_all(["podcast:liveitem", "item"]) + item = next( + filter( + lambda x: x.title.get_text() == data.get("episode") + or x.guid.get_text() == data.get("guid"), + items, + ), + None, + ) + if not item: + return + + logging.debug(item) + + social_interact = item.find("podcast:socialinteract", protocol="activitypub") + if not (social_interact and social_interact.get("uri")): + return + + logging.debug(social_interact) + + status = await mastodon.search(social_interact["uri"], resolve=True) + if not (status and status.get("statuses")): + return + + reply_to_id = status.get("statuses")[0].get("id") + if not reply_to_id: + return + + logging.debug(message) + logging.debug(reply_to_id) + + await mastodon.create_status( + status=message, + in_reply_to_id=reply_to_id, + # bug work around to reset value + params={}, + ) + + +def get_feed(feed_url): + response = requests.get(feed_url) + if response.status_code not in [requests.status_codes.codes.ok]: + return + + return BeautifulSoup(response.text, "lxml") + + +def get_feed_url_from_podcast_index(podcast_index, data): + feed_id = data.get("feedID") + if not feed_id: + return + + try: + result = podcast_index.podcasts_byfeedid(feed_id) + return result.data["feed"]["url"] + except: + pass + + +def main_message(data, value): + + numerology = number_to_numerology(value) + + sender = data.get("sender_name", "Anonymous") + + message = "" + if "podcast" in data and data["podcast"]: + message += data["podcast"] + if "episode" in data and data["episode"]: + message += f" {data['episode']}" + if "ts" in data and data["ts"]: + message += " @ {}".format(datetime.timedelta(seconds=int(data["ts"]))) + message += "\n\n" + message += f"{numerology} {sender} boosted {value} sats" + message += "\n\n" + if "message" in data and data["message"]: + message += f"\"{data['message'].strip()}\"" + message += "\n\n" + message += "via {}".format(data.get("app_name", "Unknown")) + if "url" in data and data["url"] or "feedID" in data and data["feedID"]: + message += "\n\n" + if "url" in data and data["url"]: + message += "{}\n".format(data["url"]) + if "feedID" in data and data["feedID"]: + message += "https://podcastindex.org/podcast/{}\n".format(data["feedID"]) + + return message + + +def reply_message(data, value): + numerology = number_to_numerology(value) + + sender = data.get("sender_name", "Anonymous") + + message = f"{numerology} {sender} boosted {value} sats" + + if "ts" in data and data["ts"]: + message += " @ {}".format(datetime.timedelta(seconds=int(data["ts"]))) + + message += "\n\n" + if "message" in data and data["message"]: + message += f"\"{data['message'].strip()}\"" + message += "\n\n" + message += "via {}".format(data.get("app_name", "Unknown")) + + return message + + @click.command() @click.option("--lnd-host", default="127.0.0.1") @click.option("--lnd-port", type=click.IntRange(0), default=10009) diff --git a/src/podcast_index.py b/src/podcast_index.py new file mode 100644 index 0000000..71b4870 --- /dev/null +++ b/src/podcast_index.py @@ -0,0 +1,66 @@ +import hashlib +import time +from dataclasses import dataclass +from typing import Any, Callable, Dict + +import requests + +DEFAULT_BASE_URL = "https://api.podcastindex.org/api/1.0" + + +@dataclass +class PodcastIndexError(Exception): + request: requests.Request + + +@dataclass +class PodcastIndexResponse: + request: requests.Request + data: Dict + + +@dataclass(frozen=True) +class PodcastIndex: + + api_key: str + api_secret: str + user_agent: str + base_url: str = DEFAULT_BASE_URL + requester: Any = requests.request + timestamp: Callable[[], int] = lambda: int(time.time()) + + def authorization(self, timestamp): + return str( + hashlib.sha1( + f"{self.api_key}{self.api_secret}{timestamp}".encode("utf8") + ).hexdigest() + ) + + def request(self, method: str, path: str, **kwargs) -> requests.Request: + timestamp = self.timestamp() + authorization = self.authorization(timestamp) + headers = kwargs["headers"] = kwargs.setdefault("headers", {}) + headers.setdefault("User-Agent", self.user_agent) + headers.setdefault("X-Auth-Key", self.api_key) + headers.setdefault("X-Auth-Date", str(timestamp)) + headers.setdefault("Authorization", authorization) + url = f"{self.base_url}{path}" + response = self.requester(method, url, **kwargs) + if response.status_code not in [requests.status_codes.codes.ok]: + raise PodcastIndexError(request=response) + data = response.json() + if not data.get("feed"): + raise PodcastIndexError(request=response) + return PodcastIndexResponse(request=response, data=data) + + def podcasts_byfeedid(self, feed_id: Any) -> PodcastIndexResponse: + return self.request("GET", f"/podcasts/byfeedid?id={feed_id}") + + def podcasts_byfeedurl(self, feed_url: Any) -> PodcastIndexResponse: + return self.request("GET", f"/podcasts/byfeedurl?url={feed_url}") + + def podcasts_byitunesid(self, itunes_id: Any) -> PodcastIndexResponse: + return self.request("GET", f"/podcasts/byitunesid?id={itunes_id}") + + def podcasts_byguid(self, guid: Any) -> PodcastIndexResponse: + return self.request("GET", f"/podcasts/byguid?guid={guid}") diff --git a/tests/test_podcast_index.py b/tests/test_podcast_index.py new file mode 100644 index 0000000..189975d --- /dev/null +++ b/tests/test_podcast_index.py @@ -0,0 +1,85 @@ +from unittest.mock import Mock, sentinel + +import pytest +import requests + +from src.podcast_index import PodcastIndex, PodcastIndexError, PodcastIndexResponse + + +@pytest.fixture +def requester_mock(): + return Mock(spec=requests.request, spec_set=True) + + +@pytest.fixture +def timestamp_mock(): + return Mock() + + +@pytest.fixture +def api_key(): + return "x" * 21 + + +@pytest.fixture +def api_secret(): + return "x" * 41 + + +@pytest.fixture +def user_agent(): + return "boostaccount" + + +@pytest.fixture +def provider(api_key, api_secret, user_agent, requester_mock, timestamp_mock): + return PodcastIndex( + api_key=api_key, + api_secret=api_secret, + user_agent=user_agent, + requester=requester_mock, + timestamp=timestamp_mock, + ) + + +def test_request_response(requester_mock, timestamp_mock, provider): + requester_mock.return_value.status_code = requests.status_codes.codes.ok + respones = provider.request( + sentinel.method, + sentinel.path, + ) + requester_mock.assert_called_once_with( + sentinel.method, + f"{provider.base_url}{sentinel.path}", + headers={ + "User-Agent": provider.user_agent, + "X-Auth-Key": provider.api_key, + "X-Auth-Date": str(timestamp_mock.return_value), + "Authorization": provider.authorization(timestamp_mock.return_value), + }, + ) + assert respones == PodcastIndexResponse( + request=requester_mock.return_value, + data=requester_mock.return_value.json.return_value, + ) + + +def test_request_error(requester_mock, timestamp_mock, provider): + requester_mock.return_value.status_code = requests.status_codes.codes.bad_request + expected_exception = PodcastIndexError(request=requester_mock.return_value) + with pytest.raises(PodcastIndexError) as exception: + provider.request( + sentinel.method, + sentinel.path, + ) + assert exception == expected_exception + requester_mock.assert_called_once_with( + sentinel.method, + f"{provider.base_url}{sentinel.path}", + headers={ + "User-Agent": provider.user_agent, + "X-Auth-Key": provider.api_key, + "X-Auth-Date": str(timestamp_mock.return_value), + "Authorization": provider.authorization(timestamp_mock.return_value), + }, + ) From 8bb52073dfc6048f6e519494191f0b1a76ee7c42 Mon Sep 17 00:00:00 2001 From: Valcano Bacon Date: Fri, 23 Jun 2023 21:39:53 -0700 Subject: [PATCH 3/6] boost instead of post twice --- src/mastodon/__init__.py | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/mastodon/__init__.py b/src/mastodon/__init__.py index 973c137..a49ba60 100644 --- a/src/mastodon/__init__.py +++ b/src/mastodon/__init__.py @@ -100,23 +100,27 @@ async def cli( value = invoice.value if minimum_donation is not None and value < minimum_donation: - logging.debug("Donation too low, skipping", data) + logging.debug("Donation too low, skipping: %s", data) continue - message = main_message(data, value) - - logging.debug(message) - await mastodon.create_status( - status=message, - in_reply_to_id=None, - # bug work around to reset value - params={}, + status = await send_to_social_interact( + mastodon, podcast_index, data, value ) - - await send_to_social_interact(mastodon, podcast_index, data, value) - - except: - logging.exception("error") + if status: + logging.info("Boosting: %s", status) + await mastodon.status_boost(status) + else: + message = main_message(data, value) + logging.info("Creating Status: %s", message) + await mastodon.create_status( + status=message, + in_reply_to_id=None, + # bug work around to reset value + params={}, + ) + + except atoot.MastodonError as error: + logging.exception("error: %s", error) async def send_to_social_interact(mastodon, podcast_index, data, value): @@ -162,10 +166,8 @@ async def send_to_social_interact(mastodon, podcast_index, data, value): if not reply_to_id: return - logging.debug(message) - logging.debug(reply_to_id) - - await mastodon.create_status( + logging.info("Creating Status (reply to %s): %s", reply_to_id, message) + return await mastodon.create_status( status=message, in_reply_to_id=reply_to_id, # bug work around to reset value From 56b65369d6df4655c6d477bbac0ec8bb9db81037 Mon Sep 17 00:00:00 2001 From: Alecks Gates Date: Sun, 25 Jun 2023 13:58:13 -0500 Subject: [PATCH 4/6] Initial XMPP support --- setup.py | 7 ++ src/xmpp/__init__.py | 155 +++++++++++++++++++++++++++++++++++++++++++ src/xmpp/__main__.py | 4 ++ 3 files changed, 166 insertions(+) create mode 100644 src/xmpp/__init__.py create mode 100644 src/xmpp/__main__.py diff --git a/setup.py b/setup.py index 78a59b4..ecc0b4a 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ "boostodon-leaderboard=src.mastodon:leaderboard", "boostrix=src.matrix:cli", "boostr=src.nostr:cli", + "boostxmpp=src.xmpp:cli", ], }, install_requires=[ @@ -43,5 +44,11 @@ "nostr": [ "nostr @ git+https://git@github.com/valcanobacon/python-nostr#egg=nostr", ], + "xmpp": [ + "aioxmpp>=0.13.3", + "beautifulsoup4<5,>=4.12.2", + "requests<3,>=2.31.0", + "lxml<5,>=4.9.0", + ] }, ) diff --git a/src/xmpp/__init__.py b/src/xmpp/__init__.py new file mode 100644 index 0000000..2ae9604 --- /dev/null +++ b/src/xmpp/__init__.py @@ -0,0 +1,155 @@ +import asyncio +import datetime +import functools +import json +import logging + +import aioxmpp +import click +from lndgrpc import AsyncLNDClient +from lndgrpc.aio.async_client import ln + +from ..numerology import number_to_numerology + +logging.getLogger().setLevel(logging.INFO) + + +def async_cmd(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return asyncio.run(func(*args, **kwargs)) + + return wrapper + + +@click.command() +@click.option("--lnd-host", default="127.0.0.1") +@click.option("--lnd-port", type=click.IntRange(0), default=10009) +@click.option("--lnd-macaroon", type=click.Path(exists=True), default="admin.macaroon") +@click.option("--lnd-tlscert", type=click.Path(exists=True), default="tls.cert") +@click.option("--xmpp-account-jid") +@click.option("--xmpp-account-password") +@click.option("--xmpp-room-jid") +@click.option("--xmpp-nick", default="BoostBot XMPP") +@click.option("--minimum-donation", type=int) +@click.option("--allowed-name", multiple=True) +@click.pass_context +@async_cmd +async def cli( + ctx, + lnd_host, + lnd_port, + lnd_macaroon, + lnd_tlscert, + xmpp_account_jid, + xmpp_account_password, + xmpp_room_jid, + xmpp_nick, + minimum_donation, + allowed_name, +): + ctx.ensure_object(dict) + + client = aioxmpp.Client( + aioxmpp.JID.fromstr(xmpp_account_jid), + aioxmpp.make_security_layer(xmpp_account_password) + ) + + async with client.connected() as stream: + muc_client = client.summon(aioxmpp.muc.MUCClient) + logging.info(f"Connected to {xmpp_account_jid}") + + async_lnd = AsyncLNDClient( + f"{lnd_host}:{lnd_port}", + macaroon_filepath=lnd_macaroon, + cert_filepath=lnd_tlscert, + + ) + + invoices = async_lnd.subscribe_invoices() + async for invoice in invoices: + for tlv in invoice.htlcs: + try: + data = tlv.custom_records.get(7629169) + if data is None: + continue + + data = json.loads(data) + + logging.debug(data) + + if "action" not in data or str(data["action"]).lower() != "boost": + continue + + if allowed_name: + name = data.get("name") + if not name: + continue + + if name.lower() not in [x.lower() for x in allowed_name]: + continue + + value = int(data.get("value_msat_total", 0)) // 1000 + if not value: + value = invoice.value + + if minimum_donation is not None and value < minimum_donation: + logging.debug("Donation too low, skipping: %s", data) + continue + + await send_to_xmpp(muc_client, xmpp_room_jid, xmpp_nick, data, value) + + except aioxmpp.errors.XMPPError as error: + logging.exception("error: %s", error) + + +async def send_to_xmpp( + muc_client: aioxmpp.muc.MUCClient, + xmpp_room_jid: str, + xmpp_nick: str, + data, + value +): + # TODO: Get room jid from RSS feed element + # This is different from IRC because you can join any room on any server + # (as long as the given account isn't banned and has access) + room, join_future = muc_client.join( + aioxmpp.JID.fromstr(xmpp_room_jid), + xmpp_nick, + history=None + ) + + message = muc_message(data, value) + + await join_future + + logging.info(f"Posting to XMPP room {room.jid}: {message.body}") + + await room.send_message(message) + + await room.leave() + + +def muc_message(data, value): + xmpp_message = aioxmpp.Message( + type_=aioxmpp.MessageType.GROUPCHAT + ) + + numerology = number_to_numerology(value) + + sender = data.get("sender_name", "Anonymous") + + message = f"{numerology} {sender} boosted {value} sats" + + if "ts" in data and data["ts"]: + message += " @ {}".format(datetime.timedelta(seconds=int(data["ts"]))) + + message += "\n" + if "message" in data and data["message"]: + message += f"\"{data['message'].strip()}\"" + message += "\n" + message += "via {}".format(data.get("app_name", "Unknown")) + + xmpp_message.body[None] = message + + return xmpp_message diff --git a/src/xmpp/__main__.py b/src/xmpp/__main__.py new file mode 100644 index 0000000..7c4a768 --- /dev/null +++ b/src/xmpp/__main__.py @@ -0,0 +1,4 @@ +from . import cli + +if __name__ == "__main__": + cli() From 034c53f3baa0a196f64812f0cd02b2c1e0084693 Mon Sep 17 00:00:00 2001 From: Alecks Gates Date: Sun, 2 Jul 2023 16:13:19 -0500 Subject: [PATCH 5/6] Pars XMPP JID from podcast:chat --- src/xmpp/__init__.py | 80 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/src/xmpp/__init__.py b/src/xmpp/__init__.py index 2ae9604..3b3ad9c 100644 --- a/src/xmpp/__init__.py +++ b/src/xmpp/__init__.py @@ -3,13 +3,16 @@ import functools import json import logging +from typing import Optional import aioxmpp import click +import requests +from bs4 import BeautifulSoup from lndgrpc import AsyncLNDClient -from lndgrpc.aio.async_client import ln from ..numerology import number_to_numerology +from ..podcast_index import PodcastIndex logging.getLogger().setLevel(logging.INFO) @@ -31,6 +34,8 @@ def wrapper(*args, **kwargs): @click.option("--xmpp-account-password") @click.option("--xmpp-room-jid") @click.option("--xmpp-nick", default="BoostBot XMPP") +@click.option("--podcast-index-api-key") +@click.option("--podcast-index-api-secret") @click.option("--minimum-donation", type=int) @click.option("--allowed-name", multiple=True) @click.pass_context @@ -45,11 +50,19 @@ async def cli( xmpp_account_password, xmpp_room_jid, xmpp_nick, + podcast_index_api_key, + podcast_index_api_secret, minimum_donation, allowed_name, ): ctx.ensure_object(dict) + podcast_index = PodcastIndex( + user_agent="BoostBots", + api_key=podcast_index_api_key, + api_secret=podcast_index_api_secret, + ) + client = aioxmpp.Client( aioxmpp.JID.fromstr(xmpp_account_jid), aioxmpp.make_security_layer(xmpp_account_password) @@ -97,7 +110,7 @@ async def cli( logging.debug("Donation too low, skipping: %s", data) continue - await send_to_xmpp(muc_client, xmpp_room_jid, xmpp_nick, data, value) + await send_to_xmpp(muc_client, xmpp_room_jid, xmpp_nick, podcast_index, data, value) except aioxmpp.errors.XMPPError as error: logging.exception("error: %s", error) @@ -107,16 +120,49 @@ async def send_to_xmpp( muc_client: aioxmpp.muc.MUCClient, xmpp_room_jid: str, xmpp_nick: str, + podcast_index: PodcastIndex, data, value ): - # TODO: Get room jid from RSS feed element - # This is different from IRC because you can join any room on any server - # (as long as the given account isn't banned and has access) + feed_url = data.get("url") + if not feed_url: + feed_url = get_feed_url_from_podcast_index(podcast_index, data) + if not feed_url: + return + + logging.debug(feed_url) + + soup = get_feed(feed_url) + if not soup: + return + + items = soup.find_all(["podcast:liveItem", "item"]) + item = next( + filter( + lambda x: x.title.get_text() == data.get("episode") + or x.guid.get_text() == data.get("guid"), + items, + ), + None, + ) + if not item: + return + + logging.debug(item) + + chat = item.find("podcast:chat", protocol="xmpp") + if not (chat and chat.get("space")): + return + + logging.debug(chat) + + logging.info(f"Joining room '{chat['space']}'") + room, join_future = muc_client.join( - aioxmpp.JID.fromstr(xmpp_room_jid), + aioxmpp.JID.fromstr(chat["space"]), xmpp_nick, - history=None + # Don't bother to fetch room history when joining + history=aioxmpp.muc.xso.History(maxchars=0, maxstanzas=0, seconds=0) ) message = muc_message(data, value) @@ -153,3 +199,23 @@ def muc_message(data, value): xmpp_message.body[None] = message return xmpp_message + + +def get_feed(feed_url) -> Optional[BeautifulSoup]: + response = requests.get(feed_url) + if response.status_code not in [requests.status_codes.codes.ok]: + return + + return BeautifulSoup(response.text, features="xml") + + +def get_feed_url_from_podcast_index(podcast_index: PodcastIndex, data) -> Optional[str]: + feed_id = data.get("feedID") + if not feed_id: + return + + try: + result = podcast_index.podcasts_byfeedid(feed_id) + return result.data["feed"]["url"] + except: + pass From 08675af18189a4176de3df6fe403b494f1c08e1d Mon Sep 17 00:00:00 2001 From: Alecks Gates Date: Sun, 2 Jul 2023 16:14:28 -0500 Subject: [PATCH 6/6] Fix case for podcast:liveItem in mastodon --- src/mastodon/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mastodon/__init__.py b/src/mastodon/__init__.py index a49ba60..a8e8aa1 100644 --- a/src/mastodon/__init__.py +++ b/src/mastodon/__init__.py @@ -138,7 +138,7 @@ async def send_to_social_interact(mastodon, podcast_index, data, value): if not soup: return - items = soup.find_all(["podcast:liveitem", "item"]) + items = soup.find_all(["podcast:liveItem", "item"]) item = next( filter( lambda x: x.title.get_text() == data.get("episode")