ATProto Social Bookmark
at main 247 lines 6.3 kB view raw
1/** 2 * Utility functions for the backend that are testable in isolation. 3 * These are extracted from index.ts for better testability. 4 */ 5 6// Type definitions for API responses 7export interface DidDocument { 8 alsoKnownAs?: string[]; 9} 10 11export interface DomainCheckResult { 12 result: boolean; 13} 14 15export interface OgpResult { 16 result: { 17 ogTitle?: string; 18 ogDescription?: string; 19 ogImage?: { url: string }[]; 20 }; 21} 22 23export interface DnsAnswer { 24 data: string; 25} 26 27export interface DnsResponse { 28 Answer?: DnsAnswer[]; 29} 30 31export interface PostToBookmarkRecord { 32 sub: string; 33 lang?: string; 34} 35 36// Comment locale type 37export interface CommentLocale { 38 lang: string; 39 title?: string; 40 comment?: string; 41} 42 43// Bookmark record type (the inner object, not the full record schema) 44export interface BookmarkRecord { 45 $type: 'blue.rito.feed.bookmark'; 46 subject: string; 47 createdAt?: string; 48 comments?: CommentLocale[]; 49 ogpTitle?: string; 50 ogpDescription?: string; 51 ogpImage?: string; 52 tags?: string[]; 53} 54 55/** 56 * Convert epoch microseconds to ISO datetime string 57 */ 58export function epochUsToDateTime(cursor: string | number): string { 59 return new Date(Number(cursor) / 1000).toISOString(); 60} 61 62/** 63 * Validate if URL is a valid tangled.org URL for the given user handle 64 */ 65export function isValidTangledUrl(url: string, userProfHandle: string): boolean { 66 try { 67 const u = new URL(url); 68 69 // ドメインが tangled.org であることを確認 70 if (u.hostname !== "tangled.org") return false; 71 72 // パスを分解 73 const parts = u.pathname.split("/").filter(Boolean); 74 75 // 最低でも2要素必要(例: ["@rito.blue", "skeet.el"]) 76 if (parts.length < 2) return false; 77 78 // 1個目が @handle であることを確認 79 if (parts[0] !== userProfHandle && parts[0] !== `@${userProfHandle}`) { 80 return false; 81 } 82 83 return true; 84 } catch { 85 return false; 86 } 87} 88 89/** 90 * Normalize comment text by removing hashtags, URLs, and compressing whitespace 91 */ 92export function normalizeComment(text: string): string { 93 let result = text; 94 95 // #tags を削除 96 result = result.replace(/#[^\s#]+/g, ''); 97 98 // URL / ドメイン(パス付き含む)を根こそぎ削除 99 result = result.replace( 100 /\bhttps?:\/\/[^\s]+|\b[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:\/[^\s]*)?/g, 101 '' 102 ); 103 104 // 空白を 1 つに圧縮 105 result = result 106 // 半角スペース + 全角スペースを 1 つに 107 .replace(/[  ]+/g, ' ') 108 // 行頭・行末のスペースだけ除去(改行は残る) 109 .replace(/^[  ]+|[  ]+$/gm, ''); 110 111 return result; 112} 113 114/** 115 * Extract handle from DID document's alsoKnownAs field 116 */ 117export function extractHandleFromDidDoc(didData: DidDocument, defaultHandle: string = 'no handle'): string { 118 return didData.alsoKnownAs?.[0]?.replace(/^at:\/\//, '') ?? defaultHandle; 119} 120 121/** 122 * Check if URL domain matches user handle for verification 123 */ 124export function checkDomainVerification(subject: string, handle: string): boolean { 125 try { 126 const url = new URL(subject); 127 const domain = url.hostname; 128 129 if ((url.pathname === '/' || url.pathname === '') && 130 (domain === handle || domain.endsWith(`.${handle}`))) { 131 return true; 132 } 133 return false; 134 } catch { 135 return false; 136 } 137} 138 139/** 140 * Parse DID from DNS TXT record data 141 */ 142export function parseTxtRecordForDid(txtData: string): string | null { 143 // Remove quotes and join 144 const cleaned = txtData.replace(/^"|"$/g, "").replace(/"/g, ""); 145 const didMatch = cleaned.match(/did:[\w:.]+/); 146 return didMatch ? didMatch[0] : null; 147} 148 149/** 150 * Reverse a handle to NSID prefix format 151 * Example: "rito.blue" -> "blue.rito" 152 */ 153export function reverseHandleToNsid(handle: string): string { 154 return handle.split('.').reverse().join('.'); 155} 156 157/** 158 * Build AT URI from components 159 */ 160export function buildAtUri(did: string, collection: string, rkey: string): string { 161 return `at://${did}/${collection}/${rkey}`; 162} 163 164/** 165 * Filter and normalize tags array 166 */ 167export function normalizeTagsArray(tags: string[], shouldAddVerified: boolean = false): string[] { 168 let result = (tags ?? []) 169 .filter((name: string) => name && name.trim().length > 0) 170 .filter((name: string) => name.toLowerCase() !== "verified"); 171 172 if (shouldAddVerified) { 173 result.push("Verified"); 174 } 175 176 return result; 177} 178 179/** 180 * Build subdomain for DNS TXT lookup 181 * Example: "uk.skyblur.post" -> "_lexicon.skyblur.uk" 182 */ 183export function buildDnsTxtSubdomain(nsid: string): string { 184 const parts = nsid.split('.').reverse(); 185 return `_lexicon.${parts.slice(1).join('.')}`; 186} 187 188/** 189 * Extract unique links from post facets and embed 190 */ 191export function extractLinksFromPost(record: any): string[] { 192 const links: string[] = []; 193 194 if (record.embed?.$type === 'app.bsky.embed.external' && record.embed.external?.uri) { 195 links.push(record.embed.external.uri); 196 } 197 198 return Array.from(new Set(links.filter((l): l is string => !!l))); 199} 200 201/** 202 * Extract hashtags from post facets 203 */ 204export function extractTagsFromFacets(facets: any[]): string[] { 205 const tags: string[] = []; 206 207 if (facets) { 208 for (const facet of facets) { 209 if (facet.features) { 210 for (const feature of facet.features) { 211 if (feature.$type === 'app.bsky.richtext.facet#tag' && feature.tag) { 212 tags.push(feature.tag); 213 } 214 } 215 } 216 } 217 } 218 219 return tags; 220} 221 222/** 223 * Check if post should be processed as rito.blue bookmark 224 */ 225export function shouldProcessAsRitoPost(tags: string[], via?: string): boolean { 226 if (!tags.includes('rito.blue')) { 227 return false; 228 } 229 230 if (via === 'リト' || via === 'Rito') { 231 return false; 232 } 233 234 return true; 235} 236 237/** 238 * Parse domain from URL string 239 */ 240export function parseDomainFromUrl(urlString: string): string | null { 241 try { 242 const url = new URL(urlString); 243 return url.hostname; 244 } catch { 245 return null; 246 } 247}