Iceshrimp.NET 2026.1.2-beta runs in the pasture with AuthorizedFetch on; its driver registers through the native API and takes Mastodon tokens from its OAuth form. The pair passes 225 cells. A new Iceshrimp note reaches its author's followers only, never an account it mentions or answers, until it is edited (G-0004, peer): delivery cells now say when a server holds a post only because it was addressed. The checker credits a seeder's fetch to the object fetched, not to the reply that needed it; the seeder skips the wait for an accept whose follow was already accepted; the PrivaPub driver signs its roots in again when a run reuses saved sessions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
92 lines
4.5 KiB
Python
92 lines
4.5 KiB
Python
"""Iceshrimp.NET 2026.1: accounts through its own API (/api/iceshrimp/auth/register, registrations open in the pasture),
|
|
Mastodon API tokens through its OAuth page, which takes the user's name and password itself (an ASP.NET form with an
|
|
antiforgery token). Its database keeps Misskey's shape (`note`, `poll`), with likes counted apart from reactions."""
|
|
import html
|
|
import re
|
|
import urllib.parse
|
|
|
|
from core import podman
|
|
from dialects.base import Stored
|
|
from dialects.mastodon_api import MastodonApi
|
|
|
|
OOB = "urn:ietf:wg:oauth:2.0:oob"
|
|
VIS = {"public": "public", "home": "unlisted", "followers": "followers", "specified": "direct"}
|
|
|
|
|
|
class Iceshrimp(MastodonApi):
|
|
platform = "iceshrimp"
|
|
caps = MastodonApi.caps | {"quote", "react"}
|
|
|
|
def provision(self, accounts):
|
|
app = self.http.post(self.base + "/api/v1/apps", ok={200}, form={
|
|
"client_name": "pasture-town", "redirect_uris": OOB, "scopes": "read write follow"}).json()
|
|
sessions = []
|
|
existing = {r["username"] for r in podman.psql("iceshrimp", 'select username from "user" where host is null')}
|
|
for a in accounts:
|
|
# its login answers an unknown name by cutting the connection short, so only new names are registered
|
|
if a.username not in existing:
|
|
self.http.post(self.base + "/api/iceshrimp/auth/register", ok={200},
|
|
json={"username": a.username, "password": a.password})
|
|
sessions.append(self.session_from_token(a, self._token(app, a)))
|
|
return sessions
|
|
|
|
def _token(self, app, a):
|
|
cookies = {}
|
|
|
|
def send(method, path, form=None):
|
|
headers = {"Accept": "text/html,*/*"}
|
|
if cookies:
|
|
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
|
r = self.http.request(method, self.base + path, headers=headers, form=form)
|
|
for k, v in r.headers:
|
|
if k.lower() == "set-cookie":
|
|
name, _, value = v.split(";")[0].partition("=")
|
|
cookies[name.strip()] = value.strip()
|
|
return r
|
|
|
|
query = urllib.parse.urlencode({"client_id": app["client_id"], "redirect_uri": OOB, "response_type": "code",
|
|
"scope": "read write follow"})
|
|
page = send("GET", f"/oauth/authorize?{query}").text
|
|
antiforgery = re.search(r'name="__RequestVerificationToken" value="([^"]+)"', page).group(1)
|
|
r = send("POST", f"/oauth/authorize?{query}", {
|
|
"_handler": "oauth-authorize", "__RequestVerificationToken": html.unescape(antiforgery),
|
|
"Model.Username": a.username, "Model.Password": a.password})
|
|
location = r.header("Location") or ""
|
|
code = urllib.parse.parse_qs(urllib.parse.urlsplit(location).query).get("code", [None])[0]
|
|
if code is None:
|
|
# the out-of-band page says "Your code is: <code>" in its text
|
|
text = re.sub(r"<[^>]+>", " ", re.sub(r"<(style|script).*?</\1>", "", r.text, flags=re.S))
|
|
found = re.search(r"Your code is:\s*([A-Za-z0-9_\-]{16,})", text)
|
|
code = found.group(1) if found else None
|
|
if code is None:
|
|
raise RuntimeError(f"Iceshrimp gave {a.username} no authorization code ({r.status}): {r.text[:300]}")
|
|
return self.http.post(self.base + "/oauth/token", ok={200}, form={
|
|
"grant_type": "authorization_code", "code": code, "client_id": app["client_id"],
|
|
"client_secret": app["client_secret"], "redirect_uri": OOB, "scope": "read write follow"}).json()["access_token"]
|
|
|
|
def react(self, s, uri, emoji):
|
|
sid = self.local_status_id(s, uri) or self.resolve(s, uri)
|
|
self.api(s, "POST", f"/api/v1/statuses/{sid}/react/{urllib.parse.quote(emoji)}", ok={200})
|
|
|
|
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/")}
|
|
rows = podman.psql("iceshrimp", f"""
|
|
select n.id, n.uri, n.visibility::text as visibility, n.text, n.cw, n."updatedAt" is not null as edited,
|
|
n."renoteCount" as boosts, n."repliesCount" as replies, n."likeCount" as likes, n.reactions, p.votes,
|
|
coalesce(r.uri, case when r.id is not null then '{self.base}/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
|
|
out[uri] = Stored(True, False, r["id"], VIS.get(r["visibility"], r["visibility"]), r["text"], r["cw"],
|
|
bool(r["edited"]), r["parent_uri"], r["likes"], r["boosts"], r["replies"], r["votes"],
|
|
r["reactions"] or {}, r)
|
|
return out
|