Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial XMPP support #21

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"cSpell.words": [
"atoot",
"beautifulsoup",
"bech",
"boostirc",
"boostodon",
Expand Down
10 changes: 10 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"boostodon-leaderboard=src.mastodon:leaderboard",
"boostrix=src.matrix:cli",
"boostr=src.nostr:cli",
"boostxmpp=src.xmpp:cli",
],
},
install_requires=[
Expand All @@ -33,12 +34,21 @@
],
"mastodon": [
"atoot @ git+https://[email protected]/valcanobacon/[email protected]#egg=atoot",
"beautifulsoup4<5,>=4.12.2",
"requests<3,>=2.31.0",
"lxml<5,>=4.9.0",
],
"matrix": [
"matrix-nio<1,>=0.19.0",
],
"nostr": [
"nostr @ git+https://[email protected]/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",
]
},
)
8 changes: 6 additions & 2 deletions src/irc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
183 changes: 152 additions & 31 deletions src/mastodon/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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
)
Expand Down Expand Up @@ -84,43 +100,148 @@ 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

sender = data.get("sender_name", "Anonymous")
status = await send_to_social_interact(
mastodon, podcast_index, data, value
)
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={},
)

numerology = number_to_numerology(value)
except atoot.MastodonError as error:
logging.exception("error: %s", error)

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"]
)

logging.debug(message)
await mastodon.create_status(status=message)
async def send_to_social_interact(mastodon, podcast_index, data, value):
message = reply_message(data, value)

except:
logging.exception("error")
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.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
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()
Expand Down
66 changes: 66 additions & 0 deletions src/podcast_index.py
Original file line number Diff line number Diff line change
@@ -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}")
Loading