GET /xrpc/app.bsky.actor.searchActorsTypeahead
typeahead.waow.tech
1import { CORS_HEADERS } from "./types";
2import { shouldHide, hasExplicitSlur } from "./moderation";
3
4export function clientIP(request: Request): string {
5 return request.headers.get("CF-Connecting-IP") || "unknown";
6}
7
8export function escHtml(s: string): string {
9 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
10}
11
12export function json(data: unknown, status = 200): Response {
13 return Response.json(data, { status, headers: CORS_HEADERS });
14}
15
16export function avatarUrl(did: string, cidOrUrl: string, pds?: string): string {
17 if (cidOrUrl.startsWith("https://")) return cidOrUrl;
18 if (pds && !pds.includes('.bsky.network')) {
19 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cidOrUrl)}`;
20 }
21 return `https://cdn.bsky.app/img/avatar/plain/${did}/${cidOrUrl}`;
22}
23
24export function extractAvatarCid(urlOrCid: string): string {
25 if (!urlOrCid) return '';
26 // bare CID (from PDS fallback) — no slashes
27 if (!urlOrCid.includes('/')) return urlOrCid;
28 // PDS getBlob URL — extract cid param
29 const cidParam = urlOrCid.match(/[?&]cid=([^&]+)/);
30 if (cidParam) return decodeURIComponent(cidParam[1]);
31 // bsky CDN URL — extract last path segment
32 const match = urlOrCid.match(/\/([^/]+?)(?:@[a-z]+)?$/);
33 return match?.[1] ?? '';
34}
35
36/** fetch profile directly from an actor's PDS — tries bsky profile first, then tangled.
37 * returns bare CID for avatar (not full URL) — caller reconstructs at query time via pds column. */
38export async function fetchProfileFromPds(did: string, pds: string): Promise<any | null> {
39 const collections = ['app.bsky.actor.profile', 'sh.tangled.actor.profile'];
40 for (const collection of collections) {
41 try {
42 const res = await fetch(
43 `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${collection}&rkey=self`
44 );
45 if (!res.ok) continue;
46 const data: any = await res.json();
47 const val = data?.value;
48 if (!val) continue;
49
50 return {
51 did,
52 handle: '',
53 displayName: val.displayName || '',
54 avatar: val.avatar?.ref?.$link || val.avatar?.ref?.['$link'] || '',
55 labels: [],
56 createdAt: val.createdAt || '',
57 associated: {},
58 };
59 } catch {
60 continue;
61 }
62 }
63 return null;
64}
65
66/** strip zero/false fields from associated object to match bsky's typeahead shape */
67export function cleanAssociated(assoc: any): Record<string, unknown> {
68 if (!assoc || typeof assoc !== 'object') return {};
69 const clean: Record<string, unknown> = {};
70 for (const [k, v] of Object.entries(assoc)) {
71 if (v === 0 || v === false || v === null || v === undefined) continue;
72 clean[k] = v;
73 }
74 return clean;
75}
76
77/** extract the fields we store from a bsky profile response (getProfiles/getProfile/typeahead) */
78export interface ProfileFields {
79 handle: string;
80 displayName: string;
81 avatarCid: string;
82 labels: string;
83 hidden: number;
84 createdAt: string;
85 associated: string;
86}
87
88export function extractProfileFields(profile: any, override?: 'show' | 'hide' | null): ProfileFields {
89 let hidden = shouldHide(profile.labels) ? 1 : 0;
90 if (hasExplicitSlur(profile.handle || '')) hidden = 1;
91 if (override === 'show') hidden = 0;
92 if (override === 'hide') hidden = 1;
93 const raw = profile.avatar || '';
94 const avatarCid = extractAvatarCid(raw);
95 return {
96 handle: profile.handle || '',
97 displayName: profile.displayName || '',
98 avatarCid,
99 labels: JSON.stringify(profile.labels || []),
100 hidden,
101 createdAt: profile.createdAt || '',
102 associated: JSON.stringify(cleanAssociated(profile.associated)),
103 };
104}
105
106/** strip anything that could break FTS5 syntax, preserving unicode letters/digits */
107export function sanitize(q: string): string {
108 return q.replace(/[^\p{L}\p{N}\s.-]/gu, "").trim();
109}
110
111export function html(body: string, extra?: Record<string, string> | number, status = 200): Response {
112 if (typeof extra === "number") { status = extra; extra = undefined; }
113 return new Response(body, {
114 status,
115 headers: { "Content-Type": "text/html; charset=utf-8", ...CORS_HEADERS, ...extra },
116 });
117}