re-merge fediverse an fediverse_server, so they can share utils
This commit is contained in:
parent
d7e3c69d30
commit
54ee1b3594
7 changed files with 293 additions and 185 deletions
|
@ -1,140 +0,0 @@
|
|||
import urllib.parse
|
||||
from src import IRCBot, ModuleManager, utils
|
||||
|
||||
HOSTMETA = "https://%s/.well-known/host-meta"
|
||||
WEBFINGER_DEFAULT = "https://%s/.well-known/webfinger?resource={uri}"
|
||||
WEBFINGER_HEADERS = {"Accept": "application/jrd+json"}
|
||||
|
||||
ACTIVITY_TYPE = "application/activity+json"
|
||||
ACTIVITY_HEADERS = {"Accept": ("application/ld+json; "
|
||||
'profile="https://www.w3.org/ns/activitystreams"')}
|
||||
|
||||
USERAGENT = "BitBot (%s) Fediverse" % IRCBot.VERSION
|
||||
|
||||
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("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 = _parse_username(account)
|
||||
|
||||
if not username or not instance:
|
||||
raise utils.EventError("Please provide @<user>@<instance>")
|
||||
|
||||
hostmeta = utils.http.request(HOSTMETA % instance,
|
||||
parse=True, check_content_type=False, useragent=USERAGENT)
|
||||
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 webfinger_url == None:
|
||||
self.log.debug("host-meta lookup failed for %s" % instance)
|
||||
webfinger_url = WEBFINGER_DEFAULT % instance
|
||||
webfinger_url = webfinger_url.replace("{uri}",
|
||||
"acct:%s@%s" % (username, instance))
|
||||
|
||||
webfinger = utils.http.request(webfinger_url,
|
||||
headers=WEBFINGER_HEADERS, json=True, useragent=USERAGENT)
|
||||
|
||||
activity_url = None
|
||||
for link in webfinger.data["links"]:
|
||||
if link["type"] == ACTIVITY_TYPE:
|
||||
activity_url = link["href"]
|
||||
break
|
||||
|
||||
if not activity_url:
|
||||
raise utils.EventError("Failed to find user activity feed")
|
||||
|
||||
activity = utils.http.request(activity_url,
|
||||
headers=ACTIVITY_HEADERS, json=True, useragent=USERAGENT)
|
||||
preferred_username = activity.data["preferredUsername"]
|
||||
outbox_url = activity.data["outbox"]
|
||||
|
||||
outbox = utils.http.request(outbox_url, headers=ACTIVITY_HEADERS,
|
||||
json=True, useragent=USERAGENT)
|
||||
items = None
|
||||
|
||||
if "first" in outbox.data:
|
||||
if type(outbox.data["first"]) == dict:
|
||||
# pleroma
|
||||
items = outbox.data["first"]["orderedItems"]
|
||||
else:
|
||||
# mastodon
|
||||
first = utils.http.request(outbox.data["first"],
|
||||
headers=ACTIVITY_HEADERS, json=True, useragent=USERAGENT)
|
||||
items = first.data["orderedItems"]
|
||||
else:
|
||||
items = outbox.data["orderedItems"]
|
||||
|
||||
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_url = retoot.data["attributedTo"]
|
||||
original_tooter = utils.http.request(original_tooter_url,
|
||||
headers=ACTIVITY_HEADERS, json=True, useragent=USERAGENT)
|
||||
|
||||
retooted_user = "@%s@%s" % (
|
||||
original_tooter.data["preferredUsername"],
|
||||
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" % (
|
||||
preferred_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" % (preferred_username,
|
||||
content, shorturl))
|
87
modules/fediverse/__init__.py
Normal file
87
modules/fediverse/__init__.py
Normal file
|
@ -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))
|
26
modules/fediverse/activities.py
Normal file
26
modules/fediverse/activities.py
Normal file
|
@ -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"
|
55
modules/fediverse/ap_actor.py
Normal file
55
modules/fediverse/ap_actor.py
Normal file
|
@ -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)
|
||||
|
56
modules/fediverse/ap_utils.py
Normal file
56
modules/fediverse/ap_utils.py
Normal file
|
@ -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"]
|
||||
|
|
@ -7,20 +7,24 @@ SIGNATURE_FORMAT = (
|
|||
"keyId=\"%s\",headers=\"%s\",signature=\"%s\",algorithm=\"rsa-sha256\"")
|
||||
|
||||
|
||||
def private_key(key_filename: str) -> rsa.RSAPrivateKey:
|
||||
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())
|
||||
|
||||
def signature(key: rsa.RSAPrivateKey, key_id: str,
|
||||
headers: typing.List[typing.Tuple[str, str]]) -> str:
|
||||
private_key = _private_key(key_filename)
|
||||
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 = private_key.sign(
|
||||
signature = key.key.sign(
|
||||
sign_string.encode("utf8"),
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
|
@ -3,14 +3,9 @@
|
|||
import base64, binascii, os, urllib.parse
|
||||
from src import ModuleManager, utils
|
||||
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
LD_TYPE = ("application/ld+json; "
|
||||
"profile=\"https://www.w3.org/ns/activitystreams\"")
|
||||
JRD_TYPE = "application/jrd+json"
|
||||
ACTIVITY_TYPE = "application/activity+json"
|
||||
from . import actor as ap_actor
|
||||
from . import activities as ap_activities
|
||||
from . import security as ap_security
|
||||
|
||||
ACTIVITY_SETTING_PREFIX = "ap-activity-"
|
||||
|
||||
|
@ -54,44 +49,41 @@ class Module(ModuleManager.BaseModule):
|
|||
|
||||
@utils.hook("received.command.toot")
|
||||
@utils.kwarg("min_args", 1)
|
||||
@utils.kwarg("permission", "toot")
|
||||
@utils.kwarg("permission", "fediverse")
|
||||
def toot(self, event):
|
||||
activity_id = self._make_activity(event["args"])
|
||||
event["stdout"].write("Sent toot %s" % activity_id)
|
||||
|
||||
def _federate_activity(self, activity_id, content, timestamp):
|
||||
@utils.hook("received.command.fedifollow")
|
||||
@utils.kwarg("min_args", 1)
|
||||
@utils.kwarg("permission", "fediverse")
|
||||
def fedi_follow(self, event):
|
||||
pass
|
||||
|
||||
message = {
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "Announce",
|
||||
"to": [],
|
||||
"actor": "",
|
||||
"object": ""
|
||||
}
|
||||
|
||||
|
||||
def _federate(self, data):
|
||||
def _toot(self, activity_id):
|
||||
our_username, our_instance = self._ap_self()
|
||||
key_id = self._ap_keyid_url(url_for, our_username)
|
||||
now = email.utils.formatdate(timeval=None, localtime=False, usegmt=True)
|
||||
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)
|
||||
|
||||
key = security.private_key(self.bot.config["tls-certificate"])
|
||||
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)
|
||||
|
||||
for inbox in self._get_inboxes():
|
||||
parts = urllib.parse.urlparse(inbox)
|
||||
headers = [
|
||||
["host", parts.netloc],
|
||||
["date", now]
|
||||
]
|
||||
sign_headers = headers[:]
|
||||
sign_headers.insert(0, ["(request-target)", "post %s" % parts.path])
|
||||
private_key = self._private_key()
|
||||
|
||||
signature = security.signature(key, key_id, sign_headers)
|
||||
data = ""
|
||||
request = utils.http.Request(inbox, data=data, headers=headers,
|
||||
content_type=ACTIVITY_TYPE, useragent="BitBot Fediverse")
|
||||
utils.http.request()
|
||||
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)
|
||||
|
@ -127,13 +119,13 @@ class Module(ModuleManager.BaseModule):
|
|||
|
||||
self_id = self._ap_self_url(event["url_for"], our_username)
|
||||
|
||||
event["response"].content_type = JRD_TYPE
|
||||
event["response"].content_type = consts.JRD_TYPE
|
||||
event["response"].write_json({
|
||||
"aliases": [self_id],
|
||||
"links": [{
|
||||
"href": self_id,
|
||||
"rel": "self",
|
||||
"type": ACTIVITY_TYPE
|
||||
"type": consts.ACTIVITY_TYPE
|
||||
}],
|
||||
"subject": "acct:%s" % resource
|
||||
})
|
||||
|
@ -157,7 +149,7 @@ class Module(ModuleManager.BaseModule):
|
|||
with open(cert_filename) as cert_file:
|
||||
cert = cert_file.read().strip()
|
||||
|
||||
event["response"].content_type = LD_TYPE
|
||||
event["response"].content_type = consts.LD_TYPE
|
||||
event["response"].write_json({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": self_id, "url": self_id,
|
||||
|
@ -212,7 +204,7 @@ class Module(ModuleManager.BaseModule):
|
|||
"type": "Create"
|
||||
})
|
||||
|
||||
event["response"].content_type = LD_TYPE
|
||||
event["response"].content_type = consts.LD_TYPE
|
||||
event["response"].write_json({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": outbox,
|
||||
|
@ -224,3 +216,31 @@ class Module(ModuleManager.BaseModule):
|
|||
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
|
Loading…
Reference in a new issue