ATProto Social Bookmark
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}