Files
SocialPub/tools/pasture/town/dialects/misskey_api.py
T

273 lines
11 KiB
Python
Raw Normal View History

"""Misskey and its forks (Sharkey, later CherryPick, Iceshrimp's Misskey API): every call is POST /api/<endpoint> with
the token as `i`. Accounts are made by the admin made at setup (`admin/accounts/create`); objects are read from the
`note` table. A local note has no `uri` there: its id is the last part of https://<host>/notes/<id>."""
from core import media, podman
from core.http import HttpError
from dialects.base import Driver, Made, Session, Stored, Unsupported
import os
VISIBILITY = {"public": "public", "unlisted": "home", "followers": "followers", "direct": "specified"}
VIS = {"public": "public", "home": "unlisted", "followers": "followers", "specified": "direct"}
LIKE = "❤"
class MisskeyApi(Driver):
platform = "misskey"
caps = frozenset({"post", "reply", "cw", "media", "poll", "like", "react", "boost", "bookmark", "follow", "block",
"mute", "report", "dm", "delete", "vote", "quote", "profile"})
db = "misskey"
def __init__(self, host, db=None):
super().__init__(host)
self.db = db or host.split(".")[0]
self._ids = {}
def mk(self, s_or_token, endpoint, body=None, ok=None):
body = dict(body or {})
token = s_or_token.token if isinstance(s_or_token, Session) else s_or_token
if token:
body["i"] = token
r = self.http.post(f"{self.base}/api/{endpoint}", json=body, template=f"/api/{endpoint}")
if ok is not False and r.status not in (200, 204):
raise HttpError("POST", endpoint, r.status, r.text)
return r.json() if r.body else None
def admin_token(self):
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
with open(os.path.join(here, ".state", f"{self.db}.token")) as f:
return f.read().strip()
def provision(self, accounts):
admin = self.admin_token()
sessions = []
existing = {r["username"] for r in podman.psql(self.db, 'select username from "user" where host is null')}
for a in accounts:
if a.username in existing:
r = self.http.post(f"{self.base}/api/signin-flow", json={"username": a.username, "password": a.password})
token = (r.json() or {}).get("i")
if not token:
raise RuntimeError(f"{self.host}: cannot sign {a.username} in ({r.status} {r.text[:200]})")
else:
token = self.mk(admin, "admin/accounts/create", {"username": a.username, "password": a.password})["token"]
me = self.mk(token, "i")
sessions.append(Session(a, token, me["id"], f"{self.base}/users/{me['id']}"))
return sessions
def update_profile(self, s, account):
body = {"name": account.name, "description": account.bio, "isLocked": account.locked, "isBot": account.bot,
"fields": [{"name": k, "value": v} for k, v in account.fields[:4]]}
if account.avatar_seed:
body["avatarId"] = self._upload(s, {"kind": "image", "seed": account.avatar_seed, "alt": None})
if account.header_seed:
body["bannerId"] = self._upload(s, {"kind": "image", "seed": account.header_seed, "alt": None})
self.mk(s, "i/update", body)
def _upload(self, s, item):
filename, content, ctype = media.upload(item["kind"], item["seed"])
form = {"i": s.token, "isSensitive": bool(item.get("sensitive"))}
if item.get("alt"):
form["comment"] = item["alt"]
r = self.http.post(f"{self.base}/api/drive/files/create", files={"file": (filename, content, ctype)}, form=form, ok={200})
return r.json()["id"]
# -- accounts
def lookup(self, s, acct):
key = (s.token, acct)
if key not in self._ids:
name, _, host = acct.partition("@")
body = {"username": name}
if host and host != self.host:
body["host"] = host
self._ids[key] = self.mk(s, "users/show", body)["id"]
return self._ids[key]
def follow(self, s, acct):
self.mk(s, "following/create", {"userId": self.lookup(s, acct)}, ok=False)
rel = self.relationship(s, acct)
return "accepted" if rel["following"] else "requested"
def unfollow(self, s, acct):
self.mk(s, "following/delete", {"userId": self.lookup(s, acct)})
def pending(self, s):
out = []
for req in self.mk(s, "following/requests/list", {"limit": 100}) or []:
u = req["follower"]
out.append(f"{u['username']}@{u.get('host') or self.host}")
return out
def accept(self, s, acct):
self.mk(s, "following/requests/accept", {"userId": self.lookup(s, acct)})
def reject(self, s, acct):
self.mk(s, "following/requests/reject", {"userId": self.lookup(s, acct)})
def relationship(self, s, acct):
r = self.mk(s, "users/relation", {"userId": self.lookup(s, acct)})
r = r[0] if isinstance(r, list) else r
return {"following": r.get("isFollowing"), "followed_by": r.get("isFollowed"),
"requested": r.get("hasPendingFollowRequestFromYou"), "blocking": r.get("isBlocking"),
"muting": r.get("isMuted"), "blocked_by": r.get("isBlocked")}
def block(self, s, acct):
self.mk(s, "blocking/create", {"userId": self.lookup(s, acct)})
def unblock(self, s, acct):
self.mk(s, "blocking/delete", {"userId": self.lookup(s, acct)})
def mute(self, s, acct):
self.mk(s, "mute/create", {"userId": self.lookup(s, acct)})
def report(self, s, acct, uris, comment):
self.mk(s, "users/report-abuse", {"userId": self.lookup(s, acct), "comment": comment})
# -- content
def post(self, s, spec):
if spec.kind not in ("note", "image", "video", "audio"):
raise Unsupported(self.platform, f"post a {spec.kind}")
visibility = VISIBILITY.get(spec.visibility)
if visibility is None:
raise Unsupported(self.platform, f"post with visibility {spec.visibility}")
text = spec.text
for acct in spec.mentions:
if f"@{acct}" not in text:
text = f"@{acct} {text}"
for tag in spec.tags:
if f"#{tag}" not in text:
text = f"{text} #{tag}"
body = {"text": text, "visibility": visibility}
if spec.cw:
body["cw"] = spec.cw
if visibility == "specified":
body["visibleUserIds"] = [self.lookup(s, a) for a in spec.mentions]
if spec.media:
body["fileIds"] = [self._upload(s, m) for m in spec.media[:16]]
if spec.poll:
body["poll"] = {"choices": spec.poll["options"], "multiple": bool(spec.poll.get("multiple")),
"expiredAfter": spec.poll.get("expires_in", 86400) * 1000}
if spec.reply_to_uri:
body["replyId"] = self.local_status_id(s, spec.reply_to_uri) or self.resolve(s, spec.reply_to_uri)
if spec.quote_uri:
body["renoteId"] = self.local_status_id(s, spec.quote_uri) or self.resolve(s, spec.quote_uri)
note = self.mk(s, "notes/create", body)["createdNote"]
return Made(note.get("uri") or f"{self.base}/notes/{note['id']}", note["id"], note.get("url"))
def edit(self, s, uri, spec):
if "edit" not in self.caps:
raise Unsupported(self.platform, "edit")
text = spec.text
for acct in spec.mentions:
if f"@{acct}" not in text:
text = f"@{acct} {text}"
body = {"editId": self._own(uri), "text": text, "cw": spec.cw}
self.mk(s, "notes/edit", body)
def delete(self, s, uri):
self.mk(s, "notes/delete", {"noteId": self._own(uri)})
def _note(self, s, uri):
return self.local_status_id(s, uri) or self.resolve(s, uri)
def like(self, s, uri):
self.mk(s, "notes/reactions/create", {"noteId": self._note(s, uri), "reaction": LIKE})
def unlike(self, s, uri):
self.mk(s, "notes/reactions/delete", {"noteId": self._note(s, uri)})
def react(self, s, uri, emoji):
self.mk(s, "notes/reactions/create", {"noteId": self._note(s, uri), "reaction": emoji})
def boost(self, s, uri):
self.mk(s, "notes/create", {"renoteId": self._note(s, uri), "visibility": "public"})
def unboost(self, s, uri):
self.mk(s, "notes/unrenote", {"noteId": self._note(s, uri)})
def bookmark(self, s, uri):
self.mk(s, "notes/favorites/create", {"noteId": self._note(s, uri)})
def vote(self, s, uri, choices):
nid = self._note(s, uri)
for c in choices:
self.mk(s, "notes/polls/vote", {"noteId": nid, "choice": c})
def _own(self, uri):
nid = self.local_status_id(None, uri)
if nid is None:
raise LookupError(f"{self.host} does not hold {uri}")
return nid
# -- reading back
def resolve(self, s, uri):
r = self.mk(s, "ap/show", {"uri": uri}, ok=False)
if r and r.get("type") == "Note":
return r["object"]["id"]
raise LookupError(f"{self.host} cannot resolve {uri}")
def local_status_id(self, s, uri):
row = self._rows([uri]).get(uri)
return row.local_id if row else None
def stored(self, uris):
rows = self._rows(uris)
return {u: rows.get(u) or Stored(False) for u in uris}
def seen(self, s, uris):
out = {}
for uri in uris:
nid = self.local_status_id(s, uri)
if nid is None:
out[uri] = False
continue
r = self.http.post(f"{self.base}/api/notes/show", json={"i": s.token, "noteId": nid})
note = r.json() if r.ok else None
out[uri] = bool(note) and not note.get("isHidden")
return out
def notifications(self, s):
kinds = {"reaction": "favourite", "renote": "reblog", "reply": "mention", "mention": "mention",
"follow": "follow", "receiveFollowRequest": "follow_request", "quote": "quote", "pollEnded": "poll"}
out = []
for n in self.mk(s, "i/notifications", {"limit": 100}) or []:
u = n.get("user") or {}
note = n.get("note") or {}
out.append({"type": kinds.get(n["type"], n["type"]), "acct": f"{u.get('username')}@{u.get('host') or self.host}",
"uri": note.get("uri") or (f"{self.base}/notes/{note['id']}" if note.get("id") else None)})
return out
def actor_uri(self, username):
row = podman.psql(self.db, 'select id from "user" where host is null and username = :\'p1\'', username)
return f"{self.base}/users/{row[0]['id']}" if row else None
def _rows(self, uris):
if not uris:
return {}
local = {u: u.rsplit("/", 1)[1] for u in uris if u.startswith(f"{self.base}/notes/")}
edited = "n.\"updatedAt\" is not null" if self.platform == "sharkey" else "false"
rows = podman.psql(self.db, f"""
select n.id, n.uri, n.visibility, n.text, n.cw, {edited} as edited, n."renoteCount" as boosts,
n."repliesCount" as replies, n.reactions, p.votes, coalesce(r.uri, case when r.id is not null
then 'https://{self.host}/notes/' || r.id end) as parent_uri
from note n left join poll p on p."noteId" = n.id left join note r on r.id = n."replyId"
where (n."renoteId" is null or n.text is not null)
and (n.uri = any(string_to_array(:'p1', ' ')) or n.id = any(string_to_array(:'p2', ' ')))""",
" ".join(uris), " ".join(local.values()) or "-")
out = {}
for r in rows:
uri = r["uri"] or f"{self.base}/notes/{r['id']}"
if uri not in uris:
continue
reactions = r["reactions"] or {}
out[uri] = Stored(True, False, r["id"], VIS.get(r["visibility"], r["visibility"]), r["text"], r["cw"],
bool(r["edited"]), r["parent_uri"], sum(reactions.values()), r["boosts"], r["replies"],
r["votes"], reactions, r)
return out
class Misskey(MisskeyApi):
platform = "misskey"
class Sharkey(MisskeyApi):
platform = "sharkey"
caps = MisskeyApi.caps | {"edit"}