A work-in-progress chat bot for Streamplace with chat overlay functionality
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Unified UserProfile with a bunch of info about the chatters and also sensible caching waow

+369 -530
+2
deno.json
··· 11 11 "exclude": ["**/_fresh/*"], 12 12 "imports": { 13 13 "@atcute/atproto": "npm:@atcute/atproto@^3.1.4", 14 + "@atcute/bluesky": "npm:@atcute/bluesky@^3.2.16", 14 15 "@atcute/bluesky-richtext-builder": "npm:@atcute/bluesky-richtext-builder@^2.0.4", 15 16 "@atcute/client": "npm:@atcute/client@^4.0.3", 17 + "@atcute/identity": "npm:@atcute/identity@^1.1.3", 16 18 "@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.1.3", 17 19 "@atcute/lexicons": "npm:@atcute/lexicons@^1.1.1", 18 20 "@std/dotenv": "jsr:@std/dotenv@^0.225.5",
+14 -16
deno.lock
··· 23 23 "jsr:@std/uuid@^1.0.9": "1.0.9", 24 24 "npm:@atcute/atproto@^3.1.4": "3.1.10", 25 25 "npm:@atcute/bluesky-richtext-builder@^2.0.4": "2.0.4", 26 + "npm:@atcute/bluesky@^3.2.16": "3.2.16", 26 27 "npm:@atcute/client@^4.0.3": "4.0.3", 27 - "npm:@atcute/identity-resolver@^1.1.3": "1.1.3_@atcute+identity@1.1.0", 28 + "npm:@atcute/identity-resolver@^1.1.3": "1.1.3_@atcute+identity@1.1.3", 29 + "npm:@atcute/identity@^1.1.3": "1.1.3", 28 30 "npm:@atcute/lexicons@^1.1.1": "1.2.7", 29 31 "npm:@opentelemetry/api@^1.9.0": "1.9.0", 30 32 "npm:@preact/signals@^2.2.1": "2.6.2_preact@10.28.3", ··· 141 143 "@atcute/atproto@3.1.10": { 142 144 "integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==", 143 145 "dependencies": [ 144 - "@atcute/lexicons@1.2.7" 146 + "@atcute/lexicons" 145 147 ] 146 148 }, 147 149 "@atcute/bluesky-richtext-builder@2.0.4": { 148 150 "integrity": "sha512-ydA9VWBPsBE/gbu1vYbmh7AZ8FLfxp+LE4eH5GgOTCOxwhs7Mgy1oHrHY+Er6gu6PfdoUoGso0uI3Wl3ZF/Mxg==", 149 151 "dependencies": [ 150 152 "@atcute/bluesky", 151 - "@atcute/lexicons@1.2.7" 153 + "@atcute/lexicons" 152 154 ] 153 155 }, 154 156 "@atcute/bluesky@3.2.16": { 155 157 "integrity": "sha512-phFAJNE+SCkIbCcgzjFxntS2KpGvzkLw0JA9qKIXlueF4wNreEt/D5HjnB5eRR9pV1/kcD94II9f7ZAwarf0lQ==", 156 158 "dependencies": [ 157 159 "@atcute/atproto", 158 - "@atcute/lexicons@1.2.7" 160 + "@atcute/lexicons" 159 161 ] 160 162 }, 161 163 "@atcute/client@4.0.3": { 162 164 "integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==", 163 165 "dependencies": [ 164 166 "@atcute/identity", 165 - "@atcute/lexicons@1.1.1" 167 + "@atcute/lexicons" 166 168 ] 167 169 }, 168 - "@atcute/identity-resolver@1.1.3_@atcute+identity@1.1.0": { 170 + "@atcute/identity-resolver@1.1.3_@atcute+identity@1.1.3": { 169 171 "integrity": "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==", 170 172 "dependencies": [ 171 173 "@atcute/identity", 172 - "@atcute/lexicons@1.1.1", 174 + "@atcute/lexicons", 173 175 "@atcute/util-fetch", 174 176 "@badrap/valita" 175 177 ] 176 178 }, 177 - "@atcute/identity@1.1.0": { 178 - "integrity": "sha512-6vRvRqJatDB+JUQsb+UswYmtBGQnSZcqC3a2y6H5DB/v5KcIh+6nFFtc17G0+3W9rxdk7k9M4KkgkdKf/YDNoQ==", 179 + "@atcute/identity@1.1.3": { 180 + "integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==", 179 181 "dependencies": [ 180 - "@atcute/lexicons@1.1.1", 182 + "@atcute/lexicons", 181 183 "@badrap/valita" 182 - ] 183 - }, 184 - "@atcute/lexicons@1.1.1": { 185 - "integrity": "sha512-k6qy5p3j9fJJ6ekaMPfEfp3ni4TW/XNuH9ZmsuwC0fi0tOjp+Fa8ZQakHwnqOzFt/cVBfGcmYE/lKNAbeTjgUg==", 186 - "dependencies": [ 187 - "esm-env" 188 184 ] 189 185 }, 190 186 "@atcute/lexicons@1.2.7": { ··· 428 424 "jsr:@std/dotenv@~0.225.5", 429 425 "npm:@atcute/atproto@^3.1.4", 430 426 "npm:@atcute/bluesky-richtext-builder@^2.0.4", 427 + "npm:@atcute/bluesky@^3.2.16", 431 428 "npm:@atcute/client@^4.0.3", 432 429 "npm:@atcute/identity-resolver@^1.1.3", 430 + "npm:@atcute/identity@^1.1.3", 433 431 "npm:@atcute/lexicons@^1.1.1", 434 432 "npm:@preact/signals@^2.5.0", 435 433 "npm:preact@^10.27.2"
+97 -10
utils/atcuteUtils.ts
··· 5 5 ok, 6 6 simpleFetchHandler, 7 7 } from "@atcute/client"; 8 - import type {} from "@atcute/atproto"; 8 + import { 9 + type DidDocument, 10 + getAtprotoHandle, 11 + getPdsEndpoint, 12 + isAtprotoDid, 13 + } from "@atcute/identity"; 9 14 import { 15 + AtprotoWebDidDocumentResolver, 16 + CompositeDidDocumentResolver, 10 17 CompositeHandleResolver, 11 18 DohJsonHandleResolver, 19 + PlcDidDocumentResolver, 12 20 WellKnownHandleResolver, 13 21 } from "@atcute/identity-resolver"; 14 22 import { Handle } from "@atcute/lexicons"; 15 23 import { isHandle } from "@atcute/lexicons/syntax"; 16 24 17 25 // Configuration for initializing a Streamplace client 18 - export interface StreamplaceClientConfig { 26 + export interface AtprotoClientConfig { 19 27 username: string; 20 28 password: string; 21 29 pdsHostUrl: string; 22 30 } 23 31 24 32 // Wrapper class for managing a single bot's credentials and RPC client 25 - export class StreamplaceClient { 33 + export class AtprotoClient { 26 34 private credentialManager: CredentialManager; 27 35 private rpcClient: Client; 28 - private config: StreamplaceClientConfig; 36 + private config: AtprotoClientConfig; 29 37 private initialized: boolean = false; 30 38 31 - constructor(config: StreamplaceClientConfig) { 39 + constructor(config: AtprotoClientConfig) { 32 40 this.config = config; 33 41 this.credentialManager = new CredentialManager({ 34 42 service: config.pdsHostUrl, ··· 62 70 async createMessage( 63 71 text: string, 64 72 streamerDid: string, 65 - facets?: any, 73 + facets?: Facet[], 66 74 replyTo?: { 67 75 rootUri: string; 68 76 rootCid: string; 69 77 parentUri: string; 70 78 parentCid: string; 71 79 }, 72 - ): Promise<any> { 80 + ) { 73 81 await this.ensureInitialized(); 74 82 75 83 // Create the message record according to the lexicon ··· 114 122 } 115 123 116 124 // Get shoutouts from a specific DID's repository 117 - async getShoutouts(did: Did, pdsFallback?: string): Promise<any> { 118 - await this.ensureInitialized(); 119 - 125 + async getShoutouts(did: Did, pdsFallback?: string) { 120 126 const rpc = new Client({ 121 127 // TODO: get dynamic PDS - for now using fallback or configured PDS 122 128 handler: simpleFetchHandler({ ··· 159 165 160 166 return await handleResolver.resolve(handle); 161 167 } 168 + 169 + // Get DID document 170 + export async function getDidDocument(did: Did): Promise<DidDocument> { 171 + if (!isAtprotoDid(did)) { 172 + throw new Error("Not a valid DID identifier"); 173 + } 174 + 175 + let doc: DidDocument; 176 + const resolver = new CompositeDidDocumentResolver({ 177 + methods: { 178 + plc: new PlcDidDocumentResolver({ 179 + apiUrl: "https://plc.directory", 180 + }), 181 + web: new AtprotoWebDidDocumentResolver(), 182 + }, 183 + }); 184 + 185 + try { 186 + // TODO: did:web 187 + doc = await resolver.resolve(did); 188 + } catch (e) { 189 + console.error(e); 190 + throw new Error("Error during did document resolution"); 191 + } 192 + 193 + return doc; 194 + } 195 + 196 + // Get PDS host 197 + export function getPDS(doc: DidDocument) { 198 + const pds = getPdsEndpoint(doc); 199 + return pds; 200 + } 201 + 202 + // Get handle 203 + export function getHandle(doc: DidDocument) { 204 + const handle = getAtprotoHandle(doc); 205 + return handle; 206 + } 207 + 208 + // Get app.bsky.actor.profile 209 + export async function getActorProfile(did: Did, pdsHostUrl: string) { 210 + const rpc = new Client({ 211 + handler: simpleFetchHandler({ 212 + service: pdsHostUrl, 213 + }), 214 + }); 215 + 216 + const actorProfile = await ok( 217 + rpc.get("com.atproto.repo.getRecord", { 218 + params: { 219 + repo: did, 220 + collection: "app.bsky.actor.profile", 221 + rkey: "self", 222 + }, 223 + }), 224 + ); 225 + 226 + return actorProfile; 227 + } 228 + 229 + // Get place.stream.chat.profile 230 + export async function getChatProfile(did: Did, pdsHostUrl: string) { 231 + const rpc = new Client({ 232 + handler: simpleFetchHandler({ 233 + service: pdsHostUrl, 234 + }), 235 + }); 236 + 237 + const actorProfile = await ok( 238 + rpc.get("com.atproto.repo.getRecord", { 239 + params: { 240 + repo: did, 241 + collection: "place.stream.chat.profile", 242 + rkey: "self", 243 + }, 244 + }), 245 + ); 246 + 247 + return actorProfile; 248 + }
+97 -99
utils/didResolver.ts
··· 1 - interface PlcDirectoryResponse { 2 - "@context": string[]; 3 - id: Did; 4 - alsoKnownAs?: string[]; 5 - verificationMethod: unknown[]; 6 - service: Array<{ 7 - id: string; 8 - type: string; 9 - serviceEndpoint: string; 10 - }>; 11 - } 12 - 13 - interface ChatProfileResponse { 14 - uri: string; 15 - cid: string; 16 - value: { 17 - $type: "place.stream.chat.profile"; 18 - color: { 19 - red: number; 20 - green: number; 21 - blue: number; 22 - }; 23 - }; 24 - } 25 - 26 - interface UserProfile { 27 - handle: Handle; 28 - pdsEndpoint: string; 29 - color?: { 30 - red: number; 31 - green: number; 32 - blue: number; 33 - }; 34 - } 35 - 36 - const DEFAULT_COLOR = { 37 - red: 128, 38 - green: 128, 39 - blue: 128, 40 - }; 1 + import { 2 + getActorProfile, 3 + getChatProfile, 4 + getDidDocument, 5 + getHandle, 6 + getPDS, 7 + } from "./atcuteUtils.ts"; 8 + import { fetchPronouns } from "./labelUtils.ts"; 9 + declare type ActorProfile = import("@atcute/bluesky").AppBskyActorProfile.Main; 41 10 42 - class DidToHandleResolver { 11 + class DidResolver { 43 12 private cache = new Map<Did, UserProfile>(); 44 13 private pendingRequests = new Map<Did, Promise<UserProfile>>(); 45 - private readonly plcDirectoryUrl = "https://plc.directory"; 46 14 47 15 async resolve(did: Did): Promise<UserProfile> { 48 16 // Check cache first ··· 71 39 } 72 40 73 41 private async fetchProfile(did: Did): Promise<UserProfile> { 74 - // First, get the DID document from PLC directory 75 - const plcResponse = await fetch( 76 - `${this.plcDirectoryUrl}/${encodeURIComponent(did)}`, 77 - ); 42 + try { 43 + const didDocument = await getDidDocument(did); 44 + const pdsHostUrl = getPDS(didDocument); 45 + const handle = getHandle(didDocument); 78 46 79 - if (!plcResponse.ok) { 80 - throw new Error( 81 - `Failed to resolve DID ${did}: ${plcResponse.statusText}`, 82 - ); 83 - } 47 + if (!pdsHostUrl || !handle) { 48 + throw new Error(`Error fetching PDS host or handle for ${did}`); 49 + } 84 50 85 - const plcData: PlcDirectoryResponse = await plcResponse.json(); 51 + const [actorProfileResult, chatProfileResult, pronounLabelsResult] = 52 + await Promise.allSettled([ 53 + getActorProfile(did, pdsHostUrl), 54 + getChatProfile(did, pdsHostUrl), 55 + fetchPronouns([did]), 56 + ]); 86 57 87 - // Extract handle from alsoKnownAs 88 - if (!plcData.alsoKnownAs || plcData.alsoKnownAs.length === 0) { 89 - throw new Error(`No handle found for DID ${did}`); 90 - } 91 - 92 - const handle = plcData.alsoKnownAs[0].replace(/^at:\/\//, "") as Handle; 93 - 94 - // Find the PDS endpoint 95 - const pdsService = plcData.service.find((s) => 96 - s.type === "AtprotoPersonalDataServer" 97 - ); 98 - 99 - if (!pdsService) { 100 - throw new Error(`No PDS service found for DID ${did}`); 101 - } 102 - 103 - const pdsEndpoint = pdsService.serviceEndpoint; 104 - 105 - // Now fetch the chat profile 106 - let color = DEFAULT_COLOR; 58 + // Extract values from results, handling failures gracefully 59 + const actorProfile = actorProfileResult.status === "fulfilled" 60 + ? actorProfileResult.value 61 + : null; 62 + const chatProfile = chatProfileResult.status === "fulfilled" 63 + ? chatProfileResult.value 64 + : null; 65 + const pronounLabels = pronounLabelsResult.status === "fulfilled" 66 + ? pronounLabelsResult.value.get(did) 67 + : []; 107 68 108 - try { 109 - const profileUrl = new URL( 110 - `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord`, 111 - ); 112 - profileUrl.searchParams.set("repo", did); 113 - profileUrl.searchParams.set( 114 - "collection", 115 - "place.stream.chat.profile", 116 - ); 117 - profileUrl.searchParams.set("rkey", "self"); 69 + const userProfile: UserProfile = { 70 + handle: handle as Handle, 71 + pdsEndpoint: pdsHostUrl, 72 + website: undefined, 73 + pronouns: [], 74 + description: undefined, 75 + displayName: undefined, 76 + color: undefined, 77 + }; 118 78 119 - const profileResponse = await fetch(profileUrl.toString()); 79 + // Safely extract values from actorProfile & chatProfile 80 + if (actorProfile?.value) { 81 + const actor = actorProfile.value as ActorProfile; 82 + interface BlobWithRef { 83 + $type: string; 84 + ref: { 85 + $link: string; 86 + }; 87 + mimeType?: string; 88 + size?: number; 89 + } 90 + const avatar = actor.avatar 91 + ? actor.avatar as BlobWithRef 92 + : undefined; 93 + const banner = actor.banner 94 + ? actor.banner as BlobWithRef 95 + : undefined; 120 96 121 - if (profileResponse.ok) { 122 - const profileData: ChatProfileResponse = await profileResponse 123 - .json(); 124 - if (profileData.value?.color) { 125 - color = profileData.value.color; 97 + userProfile.website = actor.website || undefined; 98 + userProfile.description = actor.description || 99 + undefined; 100 + userProfile.displayName = actor.displayName || 101 + undefined; 102 + userProfile.avatarUrl = avatar?.ref.$link 103 + ? `${pdsHostUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatar.ref.$link}` 104 + : undefined; 105 + userProfile.bannerUrl = banner?.ref.$link 106 + ? `${pdsHostUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${banner.ref.$link}` 107 + : undefined; 108 + if (actor.pronouns) { 109 + userProfile.pronouns = [ 110 + actor.pronouns, 111 + ]; 112 + } else if (pronounLabels && pronounLabels.length > 0) { 113 + userProfile.pronouns = pronounLabels; 114 + } else { 115 + userProfile.pronouns = []; 126 116 } 127 117 } 128 - // If profile doesn't exist or fetch fails, we'll use the default color 118 + if (chatProfile?.value?.color) { 119 + userProfile.color = chatProfile.value.color as { 120 + red: number; 121 + green: number; 122 + blue: number; 123 + }; 124 + } 125 + 126 + return userProfile; 129 127 } catch (error) { 130 - console.warn(`Failed to fetch chat profile for ${did}:`, error); 131 - // Continue with default color 128 + console.error(`Error fetching profile for ${did}:`, error); 129 + throw new Error( 130 + `Failed to fetch profile for ${did}: ${ 131 + error instanceof Error ? error.message : "Unknown error" 132 + }`, 133 + ); 132 134 } 135 + } 133 136 134 - return { 135 - handle, 136 - pdsEndpoint, 137 - color, 138 - }; 137 + clearUserFromCache(did: Did) { 138 + this.cache.delete(did); 139 139 } 140 140 141 - // Optional: Clear cache if needed 142 141 clearCache() { 143 142 this.cache.clear(); 144 143 } 145 144 146 - // Optional: Get cache size for monitoring 147 145 getCacheSize(): number { 148 146 return this.cache.size; 149 147 } 150 148 } 151 149 152 150 // Export singleton instance 153 - export const didResolver = new DidToHandleResolver(); 151 + export const didResolver = new DidResolver();
+19
utils/globals.d.ts
··· 1 1 declare type Did = import("@atcute/lexicons").Did; 2 2 declare type Handle = import("@atcute/lexicons").Handle; 3 + declare type Facet = import("@atcute/bluesky").AppBskyRichtextFacet.Main; 3 4 4 5 // Raw chat message as received from stream.place WebSocket 5 6 interface RawChatMessage { ··· 67 68 }; 68 69 cid: string; 69 70 }; 71 + } 72 + 73 + // Handle + app.bsky.actor.profile + place.stream.chat.profile 74 + // + pronouns from labeler for maximum info about chatter 75 + interface UserProfile { 76 + handle: Handle; 77 + pdsEndpoint: string; 78 + color?: { 79 + red: number; 80 + green: number; 81 + blue: number; 82 + }; 83 + avatarUrl?: string, 84 + bannerUrl?: string, 85 + website?: string, 86 + pronouns?: string[], 87 + description?: string, 88 + displayName?: string, 70 89 } 71 90 72 91 // Label for display (pronouns, roles, etc.)
+106 -286
utils/labelUtils.ts
··· 35 35 }; 36 36 } 37 37 38 - // Configuration for labeling service 39 - interface LabelingServiceConfig { 40 - apiBase: string; 41 - labelerDid: string; 42 - definitionsEndpoint: string; 43 - longCacheTtl?: number; // For users with labels (default 24 hours) 44 - shortCacheTtl?: number; // For users without labels (default 5 minutes) 45 - } 38 + // Generic label fetching 39 + export async function fetchLabels( 40 + apiBase: string, 41 + _definitionsEndpoint: string, 42 + dids: string[], 43 + ): Promise<Map<string, Label[]>> { 44 + const results = new Map<string, Label[]>(); 46 45 47 - export class GenericLabelingService { 48 - private labelDefinitions: Map<string, LabelDefinition> = new Map(); 49 - private cache: Map<string, MessageLabel[]> = new Map(); 50 - private cacheExpiry: Map<string, number> = new Map(); 51 - private readonly config: LabelingServiceConfig; 52 - private readonly longCacheTtl: number; // For users with labels 53 - private readonly shortCacheTtl: number; // For users without labels 54 - 55 - constructor(config: LabelingServiceConfig) { 56 - this.config = config; 57 - this.longCacheTtl = config.longCacheTtl ?? 24 * 60 * 60 * 1000; // Default 24 hours 58 - this.shortCacheTtl = config.shortCacheTtl ?? 5 * 60 * 1000; // Default 5 minutes 59 - this.loadLabelDefinitions(); 60 - } 61 - 62 - private async loadLabelDefinitions(): Promise<void> { 63 - try { 64 - const response = await fetch(this.config.definitionsEndpoint); 65 - 66 - if (!response.ok) { 67 - console.error("Failed to load label definitions"); 68 - return; 69 - } 70 - 71 - const data = await response.json() as { 72 - value: LabelServiceDefinition; 73 - }; 74 - const definitions = data.value.policies.labelValueDefinitions; 75 - 76 - for (const def of definitions) { 77 - this.labelDefinitions.set(def.identifier, def); 78 - } 79 - 80 - console.log( 81 - `Loaded ${definitions.length} label definitions from ${this.config.labelerDid}`, 82 - ); 83 - } catch (error) { 84 - console.error("Error loading label definitions:", error); 85 - } 46 + if (dids.length === 0) { 47 + return results; 86 48 } 87 49 88 - async fetchLabelsForUsers( 89 - dids: string[], 90 - labelConverter?: ( 91 - labels: Label[], 92 - definitions: Map<string, LabelDefinition>, 93 - ) => MessageLabel[], 94 - ): Promise<Map<string, MessageLabel[]>> { 95 - const results = new Map<string, MessageLabel[]>(); 96 - const uncachedDids: string[] = []; 50 + try { 51 + // Batch request for labels 52 + const uriPatterns = dids.join(","); 53 + const labelsResponse = await fetch( 54 + `${apiBase}/com.atproto.label.queryLabels?uriPatterns=${ 55 + encodeURIComponent(uriPatterns) 56 + }`, 57 + ); 97 58 98 - // Separate cached from uncached 99 - for (const did of dids) { 100 - const cached = this.getCachedLabels(did); 101 - if (cached !== null) { 102 - results.set(did, cached); 103 - } else { 104 - uncachedDids.push(did); 105 - } 106 - } 107 - 108 - if (uncachedDids.length === 0) { 59 + if (!labelsResponse.ok) { 60 + console.warn("Failed to batch fetch labels"); 109 61 return results; 110 62 } 111 63 112 - try { 113 - // Batch request for uncached DIDs 114 - const uriPatterns = uncachedDids.join(","); 115 - const response = await fetch( 116 - `${this.config.apiBase}/com.atproto.label.queryLabels?uriPatterns=${ 117 - encodeURIComponent(uriPatterns) 118 - }`, 119 - ); 120 - 121 - if (!response.ok) { 122 - console.warn("Failed to batch fetch labels"); 123 - // Return empty arrays for uncached DIDs 124 - for (const did of uncachedDids) { 125 - results.set(did, []); 126 - } 127 - return results; 128 - } 64 + const data = await labelsResponse.json() as LabelApiResponse; 129 65 130 - const data = await response.json() as LabelApiResponse; 131 - 132 - // Group labels by DID 133 - const labelsByDid = new Map<string, Label[]>(); 134 - for (const label of data.labels) { 135 - if (!labelsByDid.has(label.uri)) { 136 - labelsByDid.set(label.uri, []); 137 - } 138 - labelsByDid.get(label.uri)!.push(label); 66 + // Group labels by DID 67 + for (const label of data.labels) { 68 + if (!results.has(label.uri)) { 69 + results.set(label.uri, []); 139 70 } 71 + results.get(label.uri)!.push(label); 72 + } 140 73 141 - // Convert and cache results 142 - for (const did of uncachedDids) { 143 - const rawLabels = labelsByDid.get(did) || []; 144 - const messageLabels = labelConverter 145 - ? labelConverter(rawLabels, this.labelDefinitions) 146 - : this.defaultLabelConverter(rawLabels); 147 - 148 - results.set(did, messageLabels); 149 - this.cache.set(did, messageLabels); 150 - 151 - // Use different cache TTL based on whether user has labels 152 - const cacheTtl = messageLabels.length > 0 153 - ? this.longCacheTtl 154 - : this.shortCacheTtl; 155 - this.cacheExpiry.set(did, Date.now() + cacheTtl); 156 - } 157 - } catch (error) { 158 - console.error("Error in batch label fetch:", error); 159 - // Return empty arrays for failed requests 160 - for (const did of uncachedDids) { 74 + // Ensure all requested DIDs have an entry 75 + for (const did of dids) { 76 + if (!results.has(did)) { 161 77 results.set(did, []); 162 78 } 163 79 } 164 - 165 - return results; 166 - } 167 - 168 - // Convenience method for single user 169 - async fetchLabelsForUser( 170 - did: string, 171 - labelConverter?: ( 172 - labels: Label[], 173 - definitions: Map<string, LabelDefinition>, 174 - ) => MessageLabel[], 175 - ): Promise<MessageLabel[]> { 176 - const results = await this.fetchLabelsForUsers([did], labelConverter); 177 - return results.get(did) || []; 80 + } catch (error) { 81 + console.error("Error in batch label fetch:", error); 178 82 } 179 83 180 - private getCachedLabels(did: string): MessageLabel[] | null { 181 - const expiry = this.cacheExpiry.get(did); 182 - if (!expiry || Date.now() > expiry) { 183 - // Cache expired or doesn't exist 184 - this.cache.delete(did); 185 - this.cacheExpiry.delete(did); 186 - return null; 187 - } 188 - return this.cache.get(did) || null; 189 - } 84 + return results; 85 + } 190 86 191 - private defaultLabelConverter(labels: Label[]): MessageLabel[] { 192 - const messageLabels: MessageLabel[] = []; 193 - 194 - for (const label of labels) { 195 - if (label.neg) continue; // Skip negative labels 196 - 197 - const definition = this.labelDefinitions.get(label.val); 198 - if (!definition) { 199 - // Fallback for unknown labels 200 - messageLabels.push({ 201 - text: label.val, 202 - type: "custom", 203 - color: "#6b7280", // Default gray 204 - }); 205 - continue; 206 - } 207 - 208 - const englishLocale = definition.locales.find((l) => 209 - l.lang === "en" 210 - ); 211 - if (!englishLocale) continue; 212 - 213 - messageLabels.push({ 214 - text: englishLocale.name, 215 - type: "custom", 216 - color: "#6b7280", // Default gray 217 - }); 218 - } 87 + // Pronoun-specific fetching 88 + const PRONOUN_API_BASE = "https://api.pronouns.diy/xrpc"; 89 + const PRONOUN_DEFINITIONS_ENDPOINT = 90 + "https://pds.juli.ee/xrpc/com.atproto.repo.getRecord?repo=did:plc:wkoofae5uytcm7bjncmev6n6&collection=app.bsky.labeler.service&rkey=self"; 219 91 220 - return messageLabels; 221 - } 92 + async function loadPronounDefinitions(): Promise< 93 + Map<string, LabelDefinition> 94 + > { 95 + const definitions = new Map<string, LabelDefinition>(); 222 96 223 - // Force refresh specific users (useful for "user just set pronouns" scenario) 224 - forceRefreshUsers( 225 - dids: string[], 226 - labelConverter?: ( 227 - labels: Label[], 228 - definitions: Map<string, LabelDefinition>, 229 - ) => MessageLabel[], 230 - ): Promise<Map<string, MessageLabel[]>> { 231 - // Remove from cache first 232 - for (const did of dids) { 233 - this.cache.delete(did); 234 - this.cacheExpiry.delete(did); 97 + try { 98 + const response = await fetch(PRONOUN_DEFINITIONS_ENDPOINT); 99 + if (!response.ok) { 100 + console.error("Failed to load pronoun definitions"); 101 + return definitions; 235 102 } 236 103 237 - // Fetch fresh data 238 - return this.fetchLabelsForUsers(dids, labelConverter); 239 - } 104 + const data = await response.json() as { 105 + value: LabelServiceDefinition; 106 + }; 240 107 241 - // Clear expired cache entries periodically 242 - cleanupCache(): void { 243 - const now = Date.now(); 244 - for (const [did, expiry] of this.cacheExpiry.entries()) { 245 - if (now > expiry) { 246 - this.cache.delete(did); 247 - this.cacheExpiry.delete(did); 248 - } 108 + for (const def of data.value.policies.labelValueDefinitions) { 109 + definitions.set(def.identifier, def); 249 110 } 250 - } 251 - 252 - // Clear all cache (useful for new sessions) 253 - clearCache(): void { 254 - this.cache.clear(); 255 - this.cacheExpiry.clear(); 111 + } catch (error) { 112 + console.error("Error loading pronoun definitions:", error); 256 113 } 257 114 258 - // Get cache statistics 259 - getCacheStats(): { size: number; hitRate?: number } { 260 - return { 261 - size: this.cache.size, 262 - // You could track hit rate if needed by adding counters 263 - }; 264 - } 115 + return definitions; 265 116 } 266 117 267 - // Pronoun-specific service wrapper 268 - export class PronounService { 269 - private labelingService: GenericLabelingService; 118 + export async function fetchPronouns( 119 + dids: Did[], 120 + ): Promise<Map<Did, string[]>> { 121 + const results = new Map<Did, string[]>(); 270 122 271 - constructor() { 272 - const config: LabelingServiceConfig = { 273 - apiBase: "https://api.pronouns.diy/xrpc", 274 - labelerDid: "did:plc:wkoofae5uytcm7bjncmev6n6", 275 - definitionsEndpoint: 276 - "https://pds.juli.ee/xrpc/com.atproto.repo.getRecord?repo=did:plc:wkoofae5uytcm7bjncmev6n6&collection=app.bsky.labeler.service&rkey=self", 277 - longCacheTtl: 24 * 60 * 60 * 1000, // 24 hours for users with pronouns 278 - shortCacheTtl: 5 * 60 * 1000, // 5 minutes for users without pronouns 279 - }; 280 - 281 - this.labelingService = new GenericLabelingService(config); 123 + if (dids.length === 0) { 124 + return results; 282 125 } 283 126 284 - // Pronoun-specific label converter 285 - private pronounLabelConverter = ( 286 - labels: Label[], 287 - definitions: Map<string, LabelDefinition>, 288 - ): MessageLabel[] => { 289 - const messageLabels: MessageLabel[] = []; 290 - 291 - for (const label of labels) { 292 - if (label.neg) continue; // Skip negative labels 127 + try { 128 + // Load definitions and labels in parallel 129 + const [definitions, labelsResponse] = await Promise.all([ 130 + loadPronounDefinitions(), 131 + fetch( 132 + `${PRONOUN_API_BASE}/com.atproto.label.queryLabels?uriPatterns=${ 133 + encodeURIComponent(dids.join(",")) 134 + }`, 135 + ), 136 + ]); 293 137 294 - const definition = definitions.get(label.val); 295 - if (!definition) { 296 - // Fallback for unknown pronoun labels 297 - messageLabels.push({ 298 - text: label.val, 299 - type: "pronoun", 300 - color: "#6b7280", // Gray for pronouns 301 - }); 302 - continue; 303 - } 138 + if (!labelsResponse.ok) { 139 + console.warn("Failed to fetch pronouns"); 140 + return results; 141 + } 304 142 305 - const englishLocale = definition.locales.find((l) => 306 - l.lang === "en" 307 - ); 308 - if (!englishLocale) continue; 143 + const data = await labelsResponse.json() as LabelApiResponse; 309 144 310 - messageLabels.push({ 311 - text: englishLocale.name, 312 - type: "pronoun", 313 - color: "#6b7280", // Gray for pronouns 314 - }); 145 + // Group labels by DID 146 + const labelsByDid = new Map<string, Label[]>(); 147 + for (const label of data.labels) { 148 + if (!labelsByDid.has(label.uri)) { 149 + labelsByDid.set(label.uri, []); 150 + } 151 + labelsByDid.get(label.uri)!.push(label); 315 152 } 316 153 317 - return messageLabels; 318 - }; 319 - 320 - fetchPronounsForUsers( 321 - dids: string[], 322 - ): Promise<Map<string, MessageLabel[]>> { 323 - return this.labelingService.fetchLabelsForUsers( 324 - dids, 325 - this.pronounLabelConverter, 326 - ); 327 - } 154 + // Convert labels to pronoun strings 155 + for (const did of dids) { 156 + const labels = labelsByDid.get(did) || []; 157 + const pronouns: string[] = []; 328 158 329 - fetchPronounsForUser(did: string): Promise<MessageLabel[]> { 330 - return this.labelingService.fetchLabelsForUser( 331 - did, 332 - this.pronounLabelConverter, 333 - ); 334 - } 159 + for (const label of labels) { 160 + if (label.neg) continue; // Skip negative labels 335 161 336 - forceRefreshPronounsForUsers( 337 - dids: string[], 338 - ): Promise<Map<string, MessageLabel[]>> { 339 - return this.labelingService.forceRefreshUsers( 340 - dids, 341 - this.pronounLabelConverter, 342 - ); 343 - } 162 + const definition = definitions.get(label.val); 163 + if (!definition) { 164 + // Fallback for unknown labels 165 + pronouns.push(label.val); 166 + continue; 167 + } 344 168 345 - // Expose cache management methods 346 - cleanupCache(): void { 347 - this.labelingService.cleanupCache(); 348 - } 169 + const englishLocale = definition.locales.find((l) => 170 + l.lang === "en" 171 + ); 172 + if (englishLocale) { 173 + pronouns.push(englishLocale.name); 174 + } 175 + } 349 176 350 - clearCache(): void { 351 - this.labelingService.clearCache(); 177 + results.set(did, pronouns); 178 + } 179 + } catch (error) { 180 + console.error("Error fetching pronouns:", error); 352 181 } 353 182 354 - getCacheStats(): { size: number; hitRate?: number } { 355 - return this.labelingService.getCacheStats(); 356 - } 183 + return results; 357 184 } 358 - 359 - // testing 360 - const pronounService = new PronounService(); 361 - const pronouns = await pronounService.fetchPronounsForUser( 362 - "did:plc:o6xucog6fghiyrvp7pyqxcs3", 363 - ); 364 - console.log(pronouns);
+34 -119
utils/streamplaceBot.ts
··· 1 1 import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 2 - import { StreamplaceClient, StreamplaceClientConfig } from "./atcuteUtils.ts"; 2 + import { AtprotoClient, AtprotoClientConfig } from "./atcuteUtils.ts"; 3 3 import { didResolver } from "./didResolver.ts"; 4 4 5 5 export interface CommandHandler { 6 6 (message: JetstreamMessage, args: string[]): Promise<void> | void; 7 - } 8 - 9 - // Cached information about a chatter 10 - export interface Chatter { 11 - did: Did; 12 - handle: Handle; 13 - hasShoutout: boolean; 14 - hasBeenGreeted: boolean; 15 7 } 16 8 17 9 // Shoutout record from the repository 18 10 interface ShoutoutRecord { 19 11 user: Did; 20 12 text: string; 21 - facets?: any; 13 + facets?: Facet[]; 22 14 } 23 15 24 16 class StreamplaceBot { ··· 26 18 private commandPrefix: string; 27 19 private commands: Map<string, CommandHandler>; 28 20 private enabled: boolean; 29 - private client: StreamplaceClient; 21 + private client: AtprotoClient; 30 22 31 23 // Caching 32 - private chatters: Map<Did, Chatter> = new Map(); 33 24 private shoutouts: Map<Did, ShoutoutRecord> = new Map(); 25 + private hasBeenGreeted: Map<Did, boolean> = new Map(); 34 26 private shoutoutsLoaded: boolean = false; 35 27 36 28 /** ··· 41 33 */ 42 34 constructor( 43 35 streamerDid: Did, 44 - clientConfig: StreamplaceClientConfig, 36 + clientConfig: AtprotoClientConfig, 45 37 commandPrefix = "!", 46 38 ) { 47 39 this.streamerDid = streamerDid; 48 40 this.commandPrefix = commandPrefix; 49 41 this.commands = new Map(); 50 42 this.enabled = true; 51 - this.client = new StreamplaceClient(clientConfig); 43 + this.client = new AtprotoClient(clientConfig); 52 44 } 53 45 54 46 // Initialize the bot - must be called before use ··· 60 52 await this.loadShoutouts(); 61 53 62 54 // Register default commands 63 - await this.registerDefaultCommands(); 55 + this.registerDefaultCommands(); 64 56 65 57 console.log( 66 58 `StreamplaceBot initialized for streamer: ${this.streamerDid}`, ··· 71 63 private async loadShoutouts(): Promise<void> { 72 64 if (this.shoutoutsLoaded) return; 73 65 66 + const streamer = await this.getUserProfile(this.streamerDid); 74 67 try { 75 68 const shoutoutsData = await this.client.getShoutouts( 76 69 this.streamerDid, 77 - "https://pds.timtinkers.online", 70 + streamer.pdsEndpoint, 78 71 ); 79 72 80 73 // Cache all shoutouts ··· 82 75 const userDid = record.value.user as Did; 83 76 this.shoutouts.set(userDid, { 84 77 user: userDid, 85 - text: record.value.text, 86 - facets: record.value.facets, 78 + text: record.value.text as string, 79 + facets: record.value.facets as Facet[], 87 80 }); 88 81 89 - // Also resolve and cache the handle for users with shoutouts 90 - await this.getOrCacheChatter(userDid, true); 82 + // Also resolve and cache shoutoutees 83 + await this.getUserProfile(userDid); 91 84 } 92 85 93 86 this.shoutoutsLoaded = true; ··· 97 90 } 98 91 } 99 92 100 - // Get a chatter from cache or fetch and cache their information 101 - private async getOrCacheChatter( 102 - did: Did, 103 - hasShoutout: boolean = false, 104 - ): Promise<Chatter> { 105 - // Check if already cached 106 - let chatter = this.chatters.get(did); 107 - 108 - if (!chatter) { 109 - // Resolve handle and create new chatter entry 110 - try { 111 - const resolved = await didResolver.resolve(did); 112 - chatter = { 113 - did: did, 114 - handle: resolved.handle as Handle, 115 - hasShoutout: hasShoutout || this.shoutouts.has(did), 116 - hasBeenGreeted: false, 117 - }; 118 - this.chatters.set(did, chatter); 119 - console.log(`Cached new chatter: ${chatter.handle}`); 120 - } catch (error) { 121 - console.error(`Error resolving handle for DID ${did}:`, error); 122 - // Create minimal chatter entry even if resolution fails 123 - chatter = { 124 - did: did, 125 - handle: did as Handle, // Fallback to DID 126 - hasShoutout: hasShoutout || this.shoutouts.has(did), 127 - hasBeenGreeted: false, 128 - }; 129 - this.chatters.set(did, chatter); 130 - } 131 - } else if (hasShoutout && !chatter.hasShoutout) { 132 - // Update shoutout status if needed 133 - chatter.hasShoutout = true; 134 - } 135 - 93 + // Get a user profile from didResolver cache 94 + private async getUserProfile(did: Did) { 95 + const chatter = await didResolver.resolve(did); 136 96 return chatter; 137 97 } 138 98 139 - /** 140 - * Process an incoming chat message and respond if it's a command 141 - * @param message The chat message to process 142 - */ 99 + // Process an incoming chat message and respond if it's a command 143 100 async processMessage(message: JetstreamMessage): Promise<void> { 144 101 if (!this.enabled) return; 145 102 ··· 147 104 const text = record.text.trim(); 148 105 149 106 // Get or cache chatter information 150 - const chatter = await this.getOrCacheChatter(message.did); 107 + const chatter = await this.getUserProfile(message.did); 151 108 152 109 // Auto-greet first-time chatters with shoutout if they have one 153 - if (!chatter.hasBeenGreeted) { 154 - chatter.hasBeenGreeted = true; 110 + if (!this.hasBeenGreeted.get(message.did)) { 111 + this.hasBeenGreeted.set(message.did, true); 155 112 156 - if (chatter.hasShoutout) { 113 + if (this.shoutouts.get(message.did)) { 157 114 const shoutout = this.shoutouts.get(message.did); 158 115 if (shoutout) { 159 116 await this.sendMessage(shoutout.text, shoutout.facets); ··· 183 140 } 184 141 } 185 142 186 - /** 187 - * Register a new command 188 - * @param commandName Name of the command (without prefix) 189 - * @param handler Function to handle the command 190 - */ 143 + // @param commandName Name of the command (without prefix) 191 144 registerCommand(commandName: string, handler: CommandHandler): void { 192 145 this.commands.set(commandName.toLowerCase(), handler); 193 146 } 194 147 195 - /** 196 - * Remove a command 197 - * @param commandName Name of the command to remove 198 - */ 148 + // Remove a command 199 149 unregisterCommand(commandName: string): boolean { 200 150 return this.commands.delete(commandName.toLowerCase()); 201 151 } 202 152 203 - /** 204 - * Enable or disable the bot 205 - */ 153 + // Enable or disable the bot 206 154 setEnabled(enabled: boolean): void { 207 155 this.enabled = enabled; 208 156 } 209 157 210 - /** 211 - * Send a message to the chat 212 - * @param text Message text 213 - * @param facets Optional facets for mentions, links, etc. 214 - */ 215 - async sendMessage(text: string, facets?: any): Promise<void> { 158 + // Send a message to the chat 159 + async sendMessage(text: string, facets?: Facet[]): Promise<void> { 216 160 try { 217 - const result = await this.client.createMessage( 161 + await this.client.createMessage( 218 162 text, 219 163 this.streamerDid, 220 164 facets, 221 165 ); 222 - console.log(`Bot sent message: ${text}`); 223 166 } catch (error) { 224 167 console.error("Error sending bot message:", error); 225 168 } 226 169 } 227 170 228 - /** 229 - * Get the bot's DID 230 - */ 171 + // Get the bot's DID 231 172 getBotDid(): string | undefined { 232 173 return this.client.getDid(); 233 174 } 234 175 235 - /** 236 - * Get the streamer's DID 237 - */ 176 + // Get the streamer's DID 238 177 getStreamerDid(): string { 239 178 return this.streamerDid; 240 179 } 241 180 242 - /** 243 - * Get cached chatter information 244 - */ 245 - getChatter(did: Did): Chatter | undefined { 246 - return this.chatters.get(did); 247 - } 248 - 249 - /** 250 - * Get all cached chatters 251 - */ 252 - getAllChatters(): Map<Did, Chatter> { 253 - return new Map(this.chatters); 254 - } 255 - 256 - /** 257 - * Clear the chatter cache 258 - */ 259 - clearChatterCache(): void { 260 - this.chatters.clear(); 261 - console.log("Chatter cache cleared"); 262 - } 263 - 264 - /** 265 - * Reload shoutouts from the repository 266 - */ 181 + // Reload shoutouts from the repository 267 182 async reloadShoutouts(): Promise<void> { 268 183 this.shoutoutsLoaded = false; 269 184 this.shoutouts.clear(); ··· 290 205 return; 291 206 } 292 207 293 - const senderChatter = await this.getOrCacheChatter(message.did); 208 + const senderChatter = await this.getUserProfile(message.did); 294 209 const shoutouteeDid = message.commit.record.facets[0].features[0] 295 210 .did as Did; 296 - const shoutouteeChatter = await this.getOrCacheChatter( 211 + const shoutouteeChatter = await this.getUserProfile( 297 212 shoutouteeDid, 298 213 ); 299 214 ··· 326 241 return; 327 242 } 328 243 329 - const senderChatter = await this.getOrCacheChatter(message.did); 244 + const senderChatter = await this.getUserProfile(message.did); 330 245 const huggeeDid = message.commit.record.facets[0].features[0] 331 246 .did as Did; 332 - const huggeeChatter = await this.getOrCacheChatter(huggeeDid); 247 + const huggeeChatter = await this.getUserProfile(huggeeDid); 333 248 334 249 const { text, facets } = new RichtextBuilder() 335 250 .addMention(`@${senderChatter.handle}`, message.did)