## Resolve Bluesky / ATProto handles to DIDs via the public AppView. ## Avatar traits are still a pure function of the DID after resolution. import std/[httpclient, json, strutils] const defaultEndpoint* = "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" type HttpResponse* = object status*: int ## 0 means the request never reached the server body*: JsonNode error*: string HttpGet* = proc (url: string): HttpResponse {.gcsafe.} Identity* = object did*: string handle*: string ResolveOpts* = object endpoint*: string httpGet*: HttpGet ## test hook; nil uses the real transport func normalizeHandle*(handle: string): string = ## Strip @ and whitespace. handle.strip().strip(trailing = false, chars = {'@'}).toLowerAscii func isDid*(s: string): bool = ## True if the string is already a DID: `did::…`, method case-insensitive. let t = s.strip() if not t.toLowerAscii.startsWith("did:"): return false var i = 4 while i < t.len and t[i] in {'a'..'z', 'A'..'Z', '0'..'9'}: inc i i > 4 and i < t.len and t[i] == ':' func urlEncode(s: string): string = for c in s: if c in {'A'..'Z', 'a'..'z', '0'..'9', '_', '.', '~', '-'}: result.add c else: result.add '%' & toHex(int(uint8(c)), 2) proc httpGetJson(url: string): HttpResponse {.gcsafe.} = ## GET `url` and parse the body as JSON. `status: 0` means the request never ## reached the server — the transport itself failed — and `error` says why. {.cast(gcsafe).}: try: let client = newHttpClient(timeout = 15_000) defer: client.close() let resp = client.get(url) # `status` is "200 OK". Body parsing gets its own try: an error page from # the AppView must stay an HTTP failure, not look like a dead transport. let status = parseInt(resp.status.split(' ')[0]) var body: JsonNode try: body = parseJson(resp.body) except CatchableError: discard HttpResponse(status: status, body: body) except CatchableError as e: HttpResponse(status: 0, error: e.msg) proc resolveHandle*(handle: string, opts = ResolveOpts()): string = ## Resolve a handle to its DID. Raises if the handle cannot be resolved. let clean = normalizeHandle(handle) if clean.len == 0: raise newException(ValueError, "empty handle") if isDid(clean): return clean.strip() let endpoint = if opts.endpoint.len > 0: opts.endpoint else: defaultEndpoint url = endpoint & "?handle=" & urlEncode(clean) get = if opts.httpGet != nil: opts.httpGet else: HttpGet(httpGetJson) resp = get(url) if resp.status == 0: # No HTTP status at all: the request never left the machine. Saying "is # that a real handle?" here blames the user for our own missing transport. # Name it, and point at the offline path. raise newException(IOError, "couldn't reach the Bluesky API to resolve @" & clean & (if resp.error.len > 0: " — " & resp.error else: "") & "\nPass the DID directly (freeqsay did:plc:… …) to skip resolution.") if resp.status != 200: raise newException(IOError, "couldn't resolve @" & clean & " — is that a real Bluesky handle? (" & $resp.status & ")") let did = if resp.body != nil and resp.body.kind == JObject and resp.body.hasKey("did"): resp.body["did"].getStr else: "" if did.len == 0 or not isDid(did): raise newException(IOError, "resolveHandle returned no DID for @" & clean) did proc resolveIdentity*(handleOrDid: string, opts = ResolveOpts()): Identity = ## Accept either a handle or a DID; resolve handles, pass DIDs through. let raw = handleOrDid.strip() if isDid(raw): Identity(did: raw) else: Identity(did: resolveHandle(raw, opts), handle: normalizeHandle(raw))