aboutsummaryrefslogtreecommitdiff
path: root/modules/fediverse
diff options
context:
space:
mode:
Diffstat (limited to 'modules/fediverse')
-rw-r--r--modules/fediverse/__init__.py87
-rw-r--r--modules/fediverse/activities.py26
-rw-r--r--modules/fediverse/ap_actor.py55
-rw-r--r--modules/fediverse/ap_utils.py56
-rw-r--r--modules/fediverse/security.py36
-rw-r--r--modules/fediverse/server.py246
6 files changed, 506 insertions, 0 deletions
diff --git a/modules/fediverse/__init__.py b/modules/fediverse/__init__.py
new file mode 100644
index 00000000..41e0bfe6
--- /dev/null
+++ b/modules/fediverse/__init__.py
@@ -0,0 +1,87 @@
+import urllib.parse
+from src import IRCBot, ModuleManager, utils
+from . import ap_actor, ap_utils
+
+def _format_username(username, instance):
+ return "@%s@%s" % (username, instance)
+def _setting_parse(s):
+ username, instance = ap_utils.split_username(s)
+ if username and instance:
+ return _format_username(username, instance)
+ return None
+
+@utils.export("set", utils.FunctionSetting(_setting_parse, "fediverse",
+ help="Set your fediverse account", example="@gargron@mastodon.social"))
+class Module(ModuleManager.BaseModule):
+ _name = "Fedi"
+
+ @utils.hook("received.command.fediverse")
+ @utils.hook("received.command.fedi", alias_of="fediverse")
+ @utils.kwarg("help", "Get someone's latest toot")
+ @utils.kwarg("usage", "@<user>@<instance>")
+ def fedi(self, event):
+ account = None
+ if not event["args"]:
+ account = event["user"].get_setting("fediverse", None)
+ elif not "@" in event["args"]:
+ target = event["args_split"][0]
+ if event["server"].has_user_id(target):
+ target_user = event["server"].get_user(target)
+ account = target_user.get_setting("fediverse", None)
+ else:
+ account = event["args_split"][0]
+
+ username = None
+ instance = None
+ if account:
+ username, instance = ap_utils.split_username(account)
+
+ if not username or not instance:
+ raise utils.EventError("Please provide @<user>@<instance>")
+
+ actor_url = ap_utils.find_actor(username, instance)
+
+ if not actor_url:
+ raise utils.EventError("Failed to find actor")
+
+ actor = ap_actor.Actor(actor_url)
+ actor.load()
+ items = actor.outbox.load()
+
+ if not items:
+ raise utils.EventError("No toots found")
+
+ first_item = items[0]
+ if first_item["type"] == "Announce":
+ retoot_url = first_item["object"]
+ retoot_instance = urllib.parse.urlparse(retoot_url).hostname
+ retoot = utils.http.request(retoot_url,
+ headers=ACTIVITY_HEADERS, json=True, useragent=USERAGENT)
+
+ original_tooter = ap_actor.Actor(retoot.data["attributedTo"])
+ original_tooter.load()
+
+ original_tooter = utils.http.request(original_tooter_url,
+ headers=ACTIVITY_HEADERS, json=True, useragent=USERAGENT)
+
+ retooted_user = "@%s@%s" % (original_tooter.username,
+ retoot_instance)
+
+ shorturl = self.exports.get_one("shorturl")(
+ event["server"], retoot_url)
+ retoot_content = utils.http.strip_html(
+ retoot.data["content"])
+
+ event["stdout"].write("%s (boost %s): %s - %s" % (
+ actor.username, retooted_user, retoot_content,
+ shorturl))
+
+ elif first_item["type"] == "Create":
+ content = utils.http.strip_html(
+ first_item["object"]["content"])
+ url = first_item["object"]["id"]
+ shorturl = self.exports.get_one("shorturl")(
+ event["server"], url)
+
+ event["stdout"].write("%s: %s - %s" % (actor.username,
+ content, shorturl))
diff --git a/modules/fediverse/activities.py b/modules/fediverse/activities.py
new file mode 100644
index 00000000..8a7371c1
--- /dev/null
+++ b/modules/fediverse/activities.py
@@ -0,0 +1,26 @@
+from . import utils as ap_utils
+
+class Activity(object):
+ _type = ""
+ def __init__(self, id, object):
+ self._id = id
+ self._object = object
+ def format(self, actor):
+ return {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "actor": actor.url,
+ "id": self._id,
+ "object": self._object,
+ "type": self._type
+ }
+
+class Follow(Activity):
+ _type = "Follow"
+class Accept(Activity):
+ _type = "Accept"
+
+class Create(Activity):
+ _type = "Create"
+
+class Announce(Activity):
+ _type = "Announce"
diff --git a/modules/fediverse/ap_actor.py b/modules/fediverse/ap_actor.py
new file mode 100644
index 00000000..3f12de0d
--- /dev/null
+++ b/modules/fediverse/ap_actor.py
@@ -0,0 +1,55 @@
+import email.utils
+from src import utils
+from . import ap_utils
+
+class Actor(object):
+ def __init__(self, url):
+ self.url = url
+
+ self.username = None
+ self.inbox = None
+ self.outbox = None
+
+ def load(self):
+ data = ap_utils.activity_request(self.url)
+ self.username = data["preferredUsername"]
+ self.inbox = Inbox(data["inbox"])
+ self.outbox = Outbox(data["outbox"])
+
+class Outbox(object):
+ def __init__(self, url):
+ self._url = url
+
+ def load(self):
+ outbox = ap_utils.activity_request(self._url)
+
+ items = None
+ if "first" in outbox:
+ if type(outbox["first"]) == dict:
+ # pleroma
+ items = outbox["first"]["orderedItems"]
+ else:
+ # mastodon
+ first = ap_utils.activity_request(outbox["first"])
+ items = first["orderedItems"]
+ else:
+ items = outbox["orderedItems"]
+ return items
+
+class Inbox(object):
+ def __init__(self, url):
+ self._url = url
+ def send(activity, private_key):
+ now = email.utils.formatdate(timeval=None, localtime=False, usegmt=True)
+ parts = urllib.parse.urlparse(self._url)
+ headers = [
+ ["host", parts.netloc],
+ ["date", now]
+ ]
+ sign_headers = headers[:]
+ sign_headers.insert(0, ["(request-target)", "post %s" % parts.path])
+ signature = security.signature(private_key.key, sign_headers)
+
+ return ap_utils.request(self._url, activity.format(self),
+ method="POST", private_key=private_key)
+
diff --git a/modules/fediverse/ap_utils.py b/modules/fediverse/ap_utils.py
new file mode 100644
index 00000000..3e72d7ea
--- /dev/null
+++ b/modules/fediverse/ap_utils.py
@@ -0,0 +1,56 @@
+from src import IRCBot, utils
+
+LD_TYPE = ("application/ld+json; "
+ "profile=\"https://www.w3.org/ns/activitystreams\"")
+JRD_TYPE = "application/jrd+json"
+ACTIVITY_TYPE = "application/activity+json"
+USERAGENT = "BitBot (%s) Fediverse" % IRCBot.VERSION
+
+def split_username(s):
+ if s[0] == "@":
+ s = s[1:]
+ username, _, instance = s.partition("@")
+ if username and instance:
+ return username, instance
+ return None, None
+
+def activity_request(url, data=None, method="GET", type=ACTIVITY_TYPE):
+ content_type = None
+ headers = {}
+
+ if method == "POST":
+ content_type = type
+ else:
+ headers = {"Accept": type}
+
+ request = utils.http.Request(url, headers=headers, useragent=USERAGENT,
+ content_type=content_type, data=data, json=True)
+ return utils.http.request(request).data
+
+HOSTMETA_TEMPLATE = "https://%s/.well-known/host-meta"
+WEBFINGER_TEMPLATE = "https://%s/.well-known/webfinger?resource={uri}"
+
+def find_actor(username, instance):
+ hostmeta = HOSTMETA_TEMPLATE % instance
+ hostmeta_request = utils.http.Request(HOSTMETA_TEMPLATE % instance,
+ useragent=USERAGENT, parse=True, check_content_type=False)
+ hostmeta = utils.http.request(hostmeta_request)
+
+ webfinger_url = None
+ for item in hostmeta.data.find_all("link"):
+ if item["rel"] and item["rel"][0] == "lrdd":
+ webfinger_url = item["template"]
+ break
+
+ if not webfinger_url:
+ webfinger_url = WEBFINGER_TEMPLATE % instance
+ webfinger_url = webfinger_url.replace("{uri}",
+ "acct:%s@%s" % (username, instance), 1)
+
+ webfinger = activity_request(webfinger_url, type=JRD_TYPE)
+
+ actor_url = None
+ for link in webfinger["links"]:
+ if link["type"] == ACTIVITY_TYPE:
+ return link["href"]
+
diff --git a/modules/fediverse/security.py b/modules/fediverse/security.py
new file mode 100644
index 00000000..00fe8264
--- /dev/null
+++ b/modules/fediverse/security.py
@@ -0,0 +1,36 @@
+import base64, typing
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import padding, rsa
+from cryptography.hazmat.backends import default_backend
+
+SIGNATURE_FORMAT = (
+ "keyId=\"%s\",headers=\"%s\",signature=\"%s\",algorithm=\"rsa-sha256\"")
+
+
+def _private_key(key_filename: str) -> rsa.RSAPrivateKey:
+ with open(key_filename) as key_file:
+ return serialization.load_pem_private_key(
+ key_file.read(), password=None, backend=default_backend())
+
+class PrivateKey(object):
+ def __init__(self, filename, id):
+ self.key = _private_key(filename)
+ self.id = id
+
+def signature(key: PrivateKey, headers: typing.List[typing.Tuple[str, str]]
+ ) -> str:
+ sign_header_keys = " ".join(h[0] for h in headers)
+
+ sign_string_parts = ["%s: %s" % (k, v) for k, v in headers]
+ sign_string = "\n".join(sign_string_parts)
+
+ signature = key.key.sign(
+ sign_string.encode("utf8"),
+ padding.PSS(
+ mgf=padding.MGF1(hashes.SHA256()),
+ salt_length=padding.PSS.MAX_LENGTH),
+ hashes.SHA256()
+ )
+
+ signature = base64.b64encode(signature).decode("ascii")
+ return SIGNATURE_FORMAT % (key_id, sign_header_keys, signature)
diff --git a/modules/fediverse/server.py b/modules/fediverse/server.py
new file mode 100644
index 00000000..80e1654a
--- /dev/null
+++ b/modules/fediverse/server.py
@@ -0,0 +1,246 @@
+#--require-config tls-certificate
+
+import base64, binascii, os, urllib.parse
+from src import ModuleManager, utils
+
+from . import actor as ap_actor
+from . import activities as ap_activities
+from . import security as ap_security
+
+ACTIVITY_SETTING_PREFIX = "ap-activity-"
+
+def _parse_username(s):
+ username, _, instance = s.rpartition("@")
+ if username.startswith("@"):
+ username = username[1:]
+ if username and instance:
+ return username, instance
+ return None, None
+def _format_username(username, instance):
+ return "@%s@%s" % (username, instance)
+def _setting_parse(s):
+ username, instance = _parse_username(s)
+ if username and instance:
+ return _format_username(username, instance)
+ return None
+
+@utils.export("botset", utils.FunctionSetting(_setting_parse, "fediverse",
+ help="Set the bot's fediverse server account",
+ example="@gargron@mastodon.social"))
+class Module(ModuleManager.BaseModule):
+ _name = "Fedi"
+
+ def _random_id(self):
+ return binascii.hexlify(os.urandom(3)).decode("ascii")
+
+ def _get_activities(self):
+ activities = []
+ for setting, (content, timestamp) in self.bot.find_settings_prefix(
+ ACTIVITY_SETTING_PREFIX):
+ activity_id = setting.replace(ACTIVITY_SETTING_PREFIX, "", 1)
+ activities.append([activity_id, content, timestamp])
+ return activities
+ def _make_activity(self, content):
+ timestamp = utils.iso8601_format_now()
+ activity_id = self._random_id()
+ self.bot.set_setting("ap-activity-%s" % activity_id,
+ [content, timestamp])
+ return activity_id
+
+ @utils.hook("received.command.toot")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("permission", "fediverse")
+ def toot(self, event):
+ activity_id = self._make_activity(event["args"])
+ event["stdout"].write("Sent toot %s" % activity_id)
+
+ @utils.hook("received.command.fedifollow")
+ @utils.kwarg("min_args", 1)
+ @utils.kwarg("permission", "fediverse")
+ def fedi_follow(self, event):
+ pass
+
+ def _toot(self, activity_id):
+ our_username, our_instance = self._ap_self()
+ content, timestamp = self.bot.get_setting(
+ "ap-activity-%s" % activity_id)
+ url_for = self.exports.get_one("url-for")
+ self_id = self._ap_self_url(url_for, our_username)
+ activity_url = self._ap_activity_url(url_for, activity_id)
+
+ object = {
+ "id": activity_url,
+ "type": "Note",
+ "published": timestamp,
+ "attributedTo": self_id,
+ "content": content,
+ "to": "https://www.w3.org/ns/activitystreams#Public"
+ }
+ activity = ap_activities.Create(activity_url, object)
+
+ private_key = self._private_key()
+
+ for actor_url in self._get_actors():
+ actor = ap_actor.Actor(actor_url)
+ actor.load()
+ actor.inbox.send(activity, private_key)
+
+ def _ap_self(self):
+ our_username = self.bot.get_setting("fediverse", None)
+ return _parse_username(our_username)
+
+ def _ap_url(self, url_for, fragment, kwargs):
+ return "https://%s" % url_for("api", fragment, kwargs)
+ def _ap_self_url(self, url_for, our_username):
+ return self._ap_url(url_for, "ap-user", {"u": our_username})
+ def _ap_inbox_url(self, url_for, our_username):
+ return self._ap_url(url_for, "ap-inbox", {"u": our_username})
+ def _ap_outbox_url(self, url_for, our_username):
+ return self._ap_url(url_for, "ap-outbox", {"u": our_username})
+ def _ap_activity_url(self, url_for, activity_id):
+ return self._ap_url(url_for, "ap-activity", {"a": activity_id})
+ def _ap_keyid_url(self, url_for, our_username):
+ return "%s#key" % self._ap_self_url(url_for, our_username)
+
+ @utils.hook("api.get.ap-webfinger")
+ @utils.kwarg("authenticated", False)
+ def ap_webfinger(self, event):
+ our_username, our_instance = self._ap_self()
+
+ resource = event["params"].get("resource", None)
+ if resource.startswith("acct:"):
+ resource = resource.split(":", 1)[1]
+
+ if resource:
+ requested_username, requested_instance = _parse_username(resource)
+
+ if (requested_username == our_username and
+ requested_instance == our_instance):
+
+ self_id = self._ap_self_url(event["url_for"], our_username)
+
+ event["response"].content_type = consts.JRD_TYPE
+ event["response"].write_json({
+ "aliases": [self_id],
+ "links": [{
+ "href": self_id,
+ "rel": "self",
+ "type": consts.ACTIVITY_TYPE
+ }],
+ "subject": "acct:%s" % resource
+ })
+ else:
+ event["response"].code = 404
+ else:
+ event["response"].code = 400
+
+ @utils.hook("api.get.ap-user")
+ @utils.kwarg("authenticated", False)
+ def ap_user(self, event):
+ our_username, our_instance = self._ap_self()
+ username = event["params"].get("u", None)
+
+ if username and username == our_username:
+ self_id = self._ap_self_url(event["url_for"], our_username)
+ inbox = self._ap_inbox_url(event["url_for"], our_username)
+ outbox = self._ap_outbox_url(event["url_for"], our_username)
+
+ cert_filename = self.bot.config["tls-certificate"]
+ with open(cert_filename) as cert_file:
+ cert = cert_file.read().strip()
+
+ event["response"].content_type = consts.LD_TYPE
+ event["response"].write_json({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": self_id, "url": self_id,
+ "type": "Person",
+ "summary": "beep boop",
+ "preferredUsername": our_username, "name": our_username,
+ "inbox": inbox,
+ "outbox": outbox,
+ "publicKey": {
+ "id": "%s#key" % self_id,
+ "owner": self_id,
+ "publicKeyPem": cert
+ }
+ })
+ else:
+ event["response"].code = 404
+
+ def _prepare_activity(self, url_for, self_id, activity_id, content,
+ timestamp):
+ activity_url = self._ap_activity_url(url_for, activity_id)
+ context = "data:%s" % activity_id
+ return activity_url, {
+ "attributedTo": self_id,
+ "content": content,
+ "conversation": context, "context": context,
+ "id": activity_url, "url": activity_url,
+ "published": timestamp,
+ "summary": "", # content warning here
+ "to": "https://www.w3.org/ns/activitystreams#Public",
+ "type": "Note",
+ }
+
+ @utils.hook("api.get.ap-outbox")
+ @utils.kwarg("authenticated", False)
+ def ap_outbox(self, event):
+ our_username, our_instance = self._ap_self()
+ username = event["params"].get("u", None)
+ if username and username == our_username:
+ self_id = self._ap_self_url(event["url_for"], our_username)
+ outbox = self._ap_outbox_url(event["url_for"], our_username)
+
+ activities = []
+ for activity_id, content, timestamp in self._get_activities():
+ activity_url, activity_object = self._prepare_activity(
+ event["url_for"], self_id, activity_id, content, timestamp)
+ activities.append({
+ "actor": self_id,
+ "id": activity_url,
+ "object": activity_object,
+ "published": timestamp,
+ "to": "https://www.w3.org/ns/activitystreams#Public",
+ "type": "Create"
+ })
+
+ event["response"].content_type = consts.LD_TYPE
+ event["response"].write_json({
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": outbox,
+ "orderedItems": activities,
+ "totalItems": len(activities),
+ "type": "OrderedCollection"
+ })
+
+ else:
+ event["response"].code = 404
+
+ def _private_key(self):
+ id = self._ap_keyid_url(url_for, our_username)
+ filename = security.private_key(self.bot.config["tls-certificate"])
+ return ap_security.PrivateKey(filename, id)
+
+ @utils.hook("api.post.ap-inbox")
+ @utils.kwarg("authenticated", False)
+ def ap_inbox(self, event):
+ data = json.loads(event["data"])
+ self_id = self._ap_self_url(event["url_for"], our_username)
+
+ if data["type"] == "Follow":
+ if data["object"] == self_id:
+ new_follower = data["actor"]
+ followers = set(self.bot.get_setting("fediverse-followers", []))
+ if not new_follower in followers:
+ followers.add(new_follower)
+
+ private_key = self._private_key()
+ actor = ap_actor.Actor(new_follower)
+ accept = ap_activities.Accept(data["id"], data)
+ actor.inbox.send(accept, private_key)
+
+ follow_id = "data:%s" % str(uuid.uuid4())
+ follow = ap_activities.Follow(follow_id, self_id)
+ actor.inbox.send(follow, private_key)
+ else:
+ event["response"].code = 404