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.

Badges, icons in enriched chat messages, new ChatMessage.tsx island

+502 -240
+1
env.ts
··· 46 46 ); 47 47 export const JETSTREAM_URL = Deno.env.get("JETSTREAM_URL") || 48 48 "wss://jetstream1.us-east.bsky.network/subscribe"; 49 + export const CDN_URL = Deno.env.get("CDN_URL") || "https://cdn.bsky.app/img"; 49 50 export const FOLLOWER_MODE = Deno.env.get("FOLLOWER_MODE") === "true" 50 51 ? true 51 52 : false;
+210 -98
islands/ChatMessage.tsx
··· 1 - import { useEffect, useRef, useState } from "preact/hooks"; 1 + import { useEffect, useState } from "preact/hooks"; 2 2 3 - interface ChatMessageProps { 4 - data: EnrichedChatMessage; 5 - onExpire?: (id: string) => void; 6 - } 3 + // --------------------------------------------------------------------------- 4 + // Types 5 + // --------------------------------------------------------------------------- 7 6 8 7 interface RGB { 9 8 red: number; ··· 11 10 blue: number; 12 11 } 13 12 14 - // Customizable defaults 15 - const MESSAGE_BG_COLOR = "rgba(50, 50, 50, 0.5)"; 16 - const MESSAGE_BORDER_COLOR = "rgba(255, 255, 255, 0.3)"; 17 - const MESSAGE_BORDER_WIDTH = "2px"; 18 - const MESSAGE_BORDER_RADIUS = "8px"; 19 - const MESSAGE_BOX_SHADOW = "0 0 6px rgba(0, 0, 0, 0.5)"; 20 - const MESSAGE_SPACING = "8px"; 21 - const MESSAGE_TIMEOUT_SECONDS = 60; // Message lifetime in seconds 13 + // Accept both the base MessageView and the richer EnrichedChatMessage 14 + type ChatMessageData = MessageView | EnrichedChatMessage; 15 + 16 + interface ChatMessageProps { 17 + data: ChatMessageData; 18 + onExpire?: (id: string) => void; 19 + } 20 + 21 + // --------------------------------------------------------------------------- 22 + // Constants 23 + // --------------------------------------------------------------------------- 24 + 25 + const MESSAGE_TIMEOUT_SECONDS = 60; 22 26 const FADE_TIME_SECONDS = 1; 23 - const USE_BANNER_BACKGROUND = false; // Set to false to use solid color 24 - const BANNER_OPACITY = 0.50; // Opacity of banner background 27 + 28 + const PLACEHOLDER_AVATAR = "/placeholder.png"; 29 + const STREAMPLACE_ICON = "/streamplace.png"; 30 + 31 + const BADGE_ICONS: Record<string, string> = { 32 + "place.stream.badge.defs#mod": "/badges/mod.png", 33 + "place.stream.badge.defs#streamer": "/badges/live.png", 34 + "place.stream.badge.defs#vip": "/badges/vip.png", 35 + "place.stream.badge.defs#bot": "/badges/robot.png", 36 + }; 37 + 38 + // --------------------------------------------------------------------------- 39 + // Color helpers 40 + // --------------------------------------------------------------------------- 41 + 42 + /** Convert an RGB object to an HSL tuple [h, s, l] */ 43 + function rgbToHsl(r: number, g: number, b: number): [number, number, number] { 44 + r /= 255; 45 + g /= 255; 46 + b /= 255; 47 + const max = Math.max(r, g, b), min = Math.min(r, g, b); 48 + let h = 0, s = 0; 49 + const l = (max + min) / 2; 50 + if (max !== min) { 51 + const d = max - min; 52 + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 53 + switch (max) { 54 + case r: 55 + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; 56 + break; 57 + case g: 58 + h = ((b - r) / d + 2) / 6; 59 + break; 60 + case b: 61 + h = ((r - g) / d + 4) / 6; 62 + break; 63 + } 64 + } 65 + return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]; 66 + } 67 + 68 + /** Perceived luminance (0–1) of an RGB color */ 69 + function luminance(r: number, g: number, b: number): number { 70 + const lin = (v: number) => { 71 + v /= 255; 72 + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); 73 + }; 74 + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); 75 + } 76 + 77 + /** Return "#fff" or "#000" depending on which is more readable on the given background */ 78 + function contrastText(r: number, g: number, b: number): string { 79 + return luminance(r, g, b) > 0.35 ? "#111" : "#fff"; 80 + } 81 + 82 + /** Build css color strings from an RGB object */ 83 + function solidColor(color: RGB): string { 84 + return `rgb(${color.red}, ${color.green}, ${color.blue})`; 85 + } 86 + 87 + /** 88 + * Derive a complementary, lighter message-box color from the profile color. 89 + * Strategy: shift hue by ~15°, drop saturation, push lightness toward 85–92%. 90 + */ 91 + function messageBgColor(color: RGB): string { 92 + const [h, s] = rgbToHsl(color.red, color.green, color.blue); 93 + const shiftedHue = (h + 15) % 360; 94 + const newSat = Math.max(10, s - 20); 95 + const newLight = 88; 96 + return `hsl(${shiftedHue}, ${newSat}%, ${newLight}%)`; 97 + } 98 + 99 + /** HSL-lightness of a color (rough, for contrast decisions) */ 100 + function hslLightness(color: RGB): number { 101 + return rgbToHsl(color.red, color.green, color.blue)[2]; 102 + } 103 + 104 + // --------------------------------------------------------------------------- 105 + // Sub-components 106 + // --------------------------------------------------------------------------- 107 + 108 + function Avatar({ url, alt }: { url?: string; alt: string }) { 109 + return ( 110 + <img 111 + src={url || PLACEHOLDER_AVATAR} 112 + alt={alt} 113 + class="cm-avatar" 114 + onError={(e) => { 115 + (e.currentTarget as HTMLImageElement).src = PLACEHOLDER_AVATAR; 116 + }} 117 + /> 118 + ); 119 + } 120 + 121 + function PronounsPill( 122 + { pronouns, color }: { pronouns: string; color: RGB }, 123 + ) { 124 + const bg = solidColor(color); 125 + const fg = contrastText(color.red, color.green, color.blue); 126 + return ( 127 + <span class="cm-pronouns-pill" style={{ background: bg, color: fg }}> 128 + {pronouns} 129 + </span> 130 + ); 131 + } 132 + 133 + function BadgeImg({ badgeType }: { badgeType: string }) { 134 + const src = BADGE_ICONS[badgeType]; 135 + if (!src) return null; 136 + return <img src={src} alt={badgeType} class="cm-badge-icon" />; 137 + } 138 + 139 + // --------------------------------------------------------------------------- 140 + // Main component 141 + // --------------------------------------------------------------------------- 25 142 26 143 export default function ChatMessage({ data, onExpire }: ChatMessageProps) { 27 144 const [isFadingOut, setIsFadingOut] = useState(false); 28 - const messageRef = useRef<HTMLDivElement>(null); 29 145 30 146 useEffect(() => { 31 - // Handle message timeout 32 - if (MESSAGE_TIMEOUT_SECONDS <= 0) { 33 - return; 34 - } 35 - 36 - const timeoutId = setTimeout(() => { 147 + if (MESSAGE_TIMEOUT_SECONDS <= 0) return; 148 + const id = setTimeout(() => { 37 149 setIsFadingOut(true); 38 - setTimeout(() => { 39 - if (onExpire) { 40 - onExpire(data.uri); 41 - } 42 - }, FADE_TIME_SECONDS * 1000); 150 + setTimeout(() => onExpire?.(data.uri), FADE_TIME_SECONDS * 1000); 43 151 }, MESSAGE_TIMEOUT_SECONDS * 1000); 152 + return () => clearTimeout(id); 153 + }, [data.uri, onExpire]); 44 154 45 - return () => clearTimeout(timeoutId); 46 - }, [data.uri, onExpire]); 155 + // ------------------------------------------------------------------ 156 + // Resolve author fields that differ between MessageView / EnrichedChatMessage 157 + // ------------------------------------------------------------------ 158 + const author = data.author as { 159 + did: string; 160 + handle: string; 161 + displayName?: string; 162 + avatarUrl?: string; 163 + pronouns?: string; 164 + }; 47 165 48 - // Get colors from author profile 49 - const chatColor = data.chatProfile?.color 50 - ? rgbaToString(data.chatProfile.color, 1) 51 - : "#ffffff"; 52 - const borderColor = data.chatProfile?.color 53 - ? rgbaToString(data.chatProfile.color, 0.8) 54 - : MESSAGE_BORDER_COLOR; 166 + const displayName = author.displayName || author.handle; 167 + const avatarUrl = author.avatarUrl; 168 + const pronouns = author.pronouns; 169 + 170 + const chatProfile = data.chatProfile as { color?: RGB } | undefined; 171 + const color = chatProfile?.color; 172 + 173 + // Pill (name + pronouns) styling 174 + const pillBg = color ? solidColor(color) : "rgb(80, 80, 120)"; 175 + const pillFg = color 176 + ? contrastText(color.red, color.green, color.blue) 177 + : "#fff"; 55 178 56 - // Background: use banner if available, otherwise use color or default 57 - let backgroundStyle: Record<string, string> = {}; 179 + // Message box styling 180 + const msgBg = color ? messageBgColor(color) : "rgb(230, 230, 240)"; 181 + // For the derived lighter color we need to determine contrast. Lightness > 60 → dark text. 182 + const msgFgDark = color ? hslLightness(color) > 40 : true; // derived color is always light 183 + const msgFg = msgFgDark ? "#1a1a2e" : "#f0f0f0"; 58 184 59 - if (USE_BANNER_BACKGROUND && data.author.bannerUrl) { 60 - // Use banner as background with overlay 61 - const overlayColor = data.chatProfile?.color 62 - ? rgbaToString(data.chatProfile?.color, 0.3) 63 - : MESSAGE_BG_COLOR; 185 + // Is this a reply? 186 + const isReply = !!data.record.reply; 64 187 65 - backgroundStyle = { 66 - backgroundImage: 67 - `linear-gradient(${overlayColor}, ${overlayColor}), url(${data.author.bannerUrl})`, 68 - backgroundSize: "cover", 69 - backgroundPosition: "center", 70 - backgroundBlendMode: "overlay", 71 - }; 72 - } else if (data.chatProfile?.color) { 73 - // Use solid color background 74 - backgroundStyle = { 75 - backgroundColor: rgbaToString(data.chatProfile?.color, 0.5), 76 - }; 77 - } else { 78 - // Use default background 79 - backgroundStyle = { 80 - backgroundColor: MESSAGE_BG_COLOR, 81 - }; 82 - } 188 + // Badges 189 + const badges = data.badges ?? []; 190 + 191 + // Message text 192 + const record = data.record as { text?: string }; 193 + const text = record?.text ?? ""; 83 194 84 195 return ( 85 - <div 86 - ref={messageRef} 87 - class={`chat-message ${isFadingOut ? "fading-out" : ""}`} 88 - data-message-id={data.uri} 89 - style={{ 90 - ...backgroundStyle, 91 - border: `${MESSAGE_BORDER_WIDTH} solid ${borderColor}`, 92 - borderRadius: MESSAGE_BORDER_RADIUS, 93 - boxShadow: MESSAGE_BOX_SHADOW, 94 - }} 95 - > 96 - {data.author.avatarUrl && ( 97 - <img 98 - src={data.author.avatarUrl} 99 - alt={data.author.displayName || data.author.handle} 100 - class="chat-message-avatar" 101 - /> 102 - )} 196 + <div class={`cm-root${isFadingOut ? " cm-fading-out" : ""}`}> 197 + {/* Left column: avatar + optional pronouns pill */} 198 + <div class="cm-left-col"> 199 + <Avatar url={avatarUrl} alt={displayName} /> 200 + {pronouns && color && ( 201 + <PronounsPill pronouns={pronouns} color={color} /> 202 + )} 203 + </div> 103 204 104 - <div class="chat-message-content"> 105 - <div class="chat-message-header"> 205 + {/* Right column: floating header chips + speech bubble */} 206 + <div class="cm-right-col"> 207 + {/* Floating header row */} 208 + <div class="cm-header-row"> 209 + <img 210 + src={STREAMPLACE_ICON} 211 + alt="Streamplace" 212 + class="cm-service-icon" 213 + /> 106 214 <span 107 - class="chat-message-name" 108 - style={{ color: chatColor }} 215 + class="cm-name-chip" 216 + style={{ background: pillBg, color: pillFg }} 109 217 > 110 - {data.author.displayName || data.author.handle} 218 + {displayName} 111 219 </span> 112 - {data.author.pronouns && data.author.pronouns.length > 0 && 113 - ( 114 - <span class="chat-message-pronouns"> 115 - {data.author.pronouns} 116 - </span> 117 - )} 220 + {badges.map((badge, i) => ( 221 + <BadgeImg key={i} badgeType={badge.badgeType} /> 222 + ))} 118 223 </div> 119 224 120 - <div class="chat-message-text"> 121 - {data.record.text} 225 + {/* Speech bubble */} 226 + <div 227 + class="cm-bubble" 228 + style={{ background: msgBg, color: msgFg }} 229 + > 230 + {/* CSS triangle tail pointing left */} 231 + <span 232 + class="cm-bubble-tail" 233 + style={{ borderRightColor: msgBg }} 234 + /> 235 + 236 + {isReply && ( 237 + <span class="cm-reply-indicator" title="Reply">↩</span> 238 + )} 239 + <span class="cm-bubble-text">{text}</span> 122 240 </div> 123 241 </div> 124 242 </div> 125 243 ); 126 244 } 127 - 128 - // Convert RGB to rgba() string with optional alpha 129 - export function rgbaToString(color: RGB, alpha: number = 1): string { 130 - const clampedAlpha = Math.max(0, Math.min(1, alpha)); 131 - return `rgba(${color.red}, ${color.green}, ${color.blue}, ${clampedAlpha})`; 132 - }
+157 -64
static/chat-message.css
··· 1 - .chat-message { 2 - /* Layout */ 3 - display: flex; 4 - align-items: flex-start; 5 - gap: 8px; 6 - padding: 10px 12px; 7 - margin-top: 6px; 1 + /* ============================================================ 2 + chat-message.css — Streamplace OBS overlay chat component 3 + ============================================================ */ 8 4 9 - /* Text handling */ 10 - word-break: break-word; 11 - overflow-wrap: break-word; 5 + /* Google Font — Nunito: round, friendly, legible on stream */ 6 + @import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap"); 12 7 13 - /* Transitions and animations */ 8 + /* ------------------------------------------------------------------ */ 9 + /* Root wrapper */ 10 + /* ------------------------------------------------------------------ */ 11 + .cm-root { 12 + display: flex; 13 + align-items: flex-start; 14 + gap: 10px; 15 + margin-top: 10px; 16 + font-family: "Nunito", sans-serif; 17 + animation: cm-slide-up 0.3s cubic-bezier(0.22, 1, 0.36, 1) both; 14 18 opacity: 1; 15 - animation: slideUp 0.3s ease-out; 19 + } 16 20 17 - /* Ensure content is visible over background */ 18 - position: relative; 21 + .cm-root.cm-fading-out { 22 + opacity: 0; 23 + transition: opacity 1s ease-out; 19 24 } 20 25 21 - /* Slide up animation for new messages */ 22 - @keyframes slideUp { 26 + @keyframes cm-slide-up { 23 27 from { 24 28 opacity: 0; 25 - transform: translateY(20px); 29 + transform: translateY(14px); 26 30 } 27 31 to { 28 32 opacity: 1; ··· 30 34 } 31 35 } 32 36 33 - /* Fade out animation for expiring messages */ 34 - .chat-message.fading-out { 35 - opacity: 0; 36 - transition: opacity 1s ease-out; 37 + /* ------------------------------------------------------------------ */ 38 + /* Left column: avatar + pronouns pill */ 39 + /* ------------------------------------------------------------------ */ 40 + .cm-left-col { 41 + display: flex; 42 + flex-direction: column; 43 + align-items: center; 44 + flex-shrink: 0; 45 + /* Negative margin-bottom so pronouns pill overlaps the bottom 46 + of the avatar slightly */ 47 + gap: 0; 48 + position: relative; 49 + width: 48px; 37 50 } 38 51 39 52 /* Avatar */ 40 - .chat-message-avatar { 41 - width: 40px; 42 - height: 40px; 53 + .cm-avatar { 54 + width: 48px; 55 + height: 48px; 43 56 border-radius: 50%; 44 - flex-shrink: 0; 45 57 object-fit: cover; 46 - border: 2px solid rgba(255, 255, 255, 0.2); 47 - background-color: rgba(0, 0, 0, 0.2); 58 + display: block; 59 + /* Subtle ring so the avatar floats cleanly over any stream BG */ 60 + box-shadow: 61 + 0 0 0 2px rgba(255, 255, 255, 0.25), 62 + 0 4px 12px rgba(0, 0, 0, 0.45); 63 + } 64 + 65 + /* Pronouns pill — overlaps the avatar's bottom edge */ 66 + .cm-pronouns-pill { 67 + display: inline-block; 68 + margin-top: -8px; /* overlap avatar */ 69 + padding: 1px 7px; 70 + border-radius: 999px; 71 + font-size: 0.65em; 72 + font-weight: 700; 73 + letter-spacing: 0.02em; 74 + white-space: nowrap; 75 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); 76 + line-height: 1.6; 77 + /* color and background-color are set inline */ 48 78 } 49 79 50 - /* Content container */ 51 - .chat-message-content { 80 + /* ------------------------------------------------------------------ */ 81 + /* Right column: header row + bubble */ 82 + /* ------------------------------------------------------------------ */ 83 + .cm-right-col { 52 84 display: flex; 53 85 flex-direction: column; 86 + gap: 0; 54 87 flex: 1; 55 - min-width: 0; /* Allows text to wrap properly */ 56 - gap: 4px; 88 + min-width: 0; 57 89 } 58 90 59 - /* Header (name + pronouns) */ 60 - .chat-message-header { 91 + /* ------------------------------------------------------------------ */ 92 + /* Header row: service icon · name chip · badges */ 93 + /* ------------------------------------------------------------------ */ 94 + .cm-header-row { 61 95 display: flex; 62 - align-items: baseline; 63 - gap: 8px; 64 - flex-wrap: wrap; 96 + align-items: center; 97 + gap: 5px; 98 + /* Float the header slightly below the bubble's top edge so the 99 + name chip overlaps it — we achieve this with a small positive 100 + margin-bottom that gets consumed by the bubble's negative 101 + margin-top. */ 102 + margin-bottom: -1px; 103 + /* Sit above the bubble in z-order */ 104 + position: relative; 105 + z-index: 1; 106 + padding-left: 14px; /* indent to clear the bubble tail */ 65 107 } 66 108 67 - /* Name */ 68 - .chat-message-name { 69 - font-weight: bold; 70 - font-size: 1em; 71 - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); 72 - line-height: 1.2; 109 + /* Streamplace service icon */ 110 + .cm-service-icon { 111 + width: 16px; 112 + height: 16px; 113 + border-radius: 4px; 114 + object-fit: contain; 115 + flex-shrink: 0; 116 + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); 73 117 } 74 118 75 - /* Pronouns */ 76 - .chat-message-pronouns { 77 - font-size: 0.85em; 78 - opacity: 0.8; 79 - color: rgba(255, 255, 255, 0.9); 80 - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); 119 + /* Name chip */ 120 + .cm-name-chip { 121 + display: inline-block; 122 + padding: 2px 10px; 123 + border-radius: 999px; 124 + font-size: 0.78em; 125 + font-weight: 800; 126 + letter-spacing: 0.01em; 127 + white-space: nowrap; 128 + overflow: hidden; 129 + text-overflow: ellipsis; 130 + max-width: 180px; 131 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); 132 + line-height: 1.6; 133 + /* color and background are set inline */ 81 134 } 82 135 83 - /* Message text */ 84 - .chat-message-text { 85 - line-height: 1.4; 86 - color: rgba(255, 255, 255, 0.95); 87 - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); 88 - font-size: 0.95em; 136 + /* Badge icons */ 137 + .cm-badge-icon { 138 + width: 18px; 139 + height: 18px; 140 + object-fit: contain; 141 + flex-shrink: 0; 142 + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5)); 89 143 } 90 144 91 - /* Emotes in messages */ 92 - .chat-message-emote { 93 - width: 28px; 94 - height: auto; 145 + /* ------------------------------------------------------------------ */ 146 + /* Speech bubble */ 147 + /* ------------------------------------------------------------------ */ 148 + .cm-bubble { 149 + position: relative; 150 + border-radius: 12px; 151 + padding: 8px 12px 8px 16px; 152 + word-break: break-word; 153 + overflow-wrap: break-word; 154 + box-shadow: 155 + 0 4px 14px rgba(0, 0, 0, 0.35), 156 + 0 1px 3px rgba(0, 0, 0, 0.2); 157 + /* color and background are set inline */ 158 + /* Slight overlap with the header row chips */ 159 + margin-top: 0; 160 + } 161 + 162 + /* Triangle tail — points left toward the avatar */ 163 + .cm-bubble-tail { 164 + position: absolute; 165 + left: -9px; 166 + top: 14px; 167 + width: 0; 168 + height: 0; 169 + border-top: 7px solid transparent; 170 + border-bottom: 7px solid transparent; 171 + border-right-width: 10px; 172 + border-right-style: solid; 173 + /* border-right-color is set inline to match msgBg */ 174 + } 175 + 176 + /* ------------------------------------------------------------------ */ 177 + /* Reply indicator */ 178 + /* ------------------------------------------------------------------ */ 179 + .cm-reply-indicator { 180 + display: inline-block; 181 + font-size: 0.85em; 182 + margin-right: 5px; 183 + opacity: 0.6; 95 184 vertical-align: middle; 185 + /* rotated slightly so it reads as "reply" rather than "undo" */ 186 + transform: scaleX(-1); 96 187 display: inline-block; 97 - margin: 0 2px; 98 188 } 99 189 100 - /* Badges (if you add them later) */ 101 - .chat-message-badge { 102 - width: 18px; 103 - height: 18px; 104 - margin-right: 4px; 190 + /* ------------------------------------------------------------------ */ 191 + /* Message text */ 192 + /* ------------------------------------------------------------------ */ 193 + .cm-bubble-text { 194 + font-size: 0.95em; 195 + font-weight: 600; 196 + line-height: 1.45; 197 + /* color is set inline via parent */ 105 198 }
+7 -46
utils/didResolver.ts
··· 1 1 import { getDidDocument, getHandle, getPDS, getRecord } from "./atcuteUtils.ts"; 2 - declare type ActorProfile = import("@atcute/bluesky").AppBskyActorProfile.Main; 3 2 4 3 class DidResolver { 5 4 private cache = new Map<Did, UserProfile>(); ··· 57 56 58 57 // Extract values from results, handling failures gracefully 59 58 const actorProfile = actorProfileResult.status === "fulfilled" 60 - ? actorProfileResult.value 59 + ? actorProfileResult.value?.value 61 60 : null; 62 61 const chatProfile = chatProfileResult.status === "fulfilled" 63 - ? chatProfileResult.value 62 + ? chatProfileResult.value?.value 64 63 : null; 65 64 66 65 const userProfile: UserProfile = { 67 66 handle: handle as Handle, 68 67 pdsEndpoint: pdsHostUrl, 69 - website: undefined, 70 - pronouns: undefined, 71 - description: undefined, 72 - displayName: undefined, 73 - color: undefined, 74 68 }; 75 69 76 - // Safely extract values from actorProfile & chatProfile 77 - if (actorProfile?.value) { 78 - const actor = actorProfile.value as ActorProfile; 79 - interface BlobWithRef { 80 - $type: string; 81 - ref: { 82 - $link: string; 83 - }; 84 - mimeType?: string; 85 - size?: number; 86 - } 87 - const avatar = actor.avatar 88 - ? actor.avatar as BlobWithRef 89 - : undefined; 90 - const banner = actor.banner 91 - ? actor.banner as BlobWithRef 92 - : undefined; 93 - 94 - userProfile.website = actor.website || undefined; 95 - userProfile.description = actor.description || 96 - undefined; 97 - userProfile.displayName = actor.displayName || 98 - undefined; 99 - userProfile.avatarUrl = avatar?.ref.$link 100 - ? `${pdsHostUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatar.ref.$link}` 101 - : undefined; 102 - userProfile.bannerUrl = banner?.ref.$link 103 - ? `${pdsHostUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${banner.ref.$link}` 104 - : undefined; 105 - if (actor.pronouns) { 106 - userProfile.pronouns = actor.pronouns; 107 - } 70 + if (actorProfile) { 71 + userProfile.actorProfile = actorProfile as ActorProfile; 108 72 } 109 - if (chatProfile?.value?.color) { 110 - userProfile.color = chatProfile.value.color as { 111 - red: number; 112 - green: number; 113 - blue: number; 114 - }; 73 + 74 + if (chatProfile) { 75 + userProfile.chatProfile = chatProfile as ChatProfile; 115 76 } 116 77 117 78 return userProfile;
+71 -17
utils/eventHandler.ts
··· 2 2 import { botInstances } from "../main.ts"; 3 3 import { didResolver } from "./didResolver.ts"; 4 4 import { PlaceStreamChatMessage } from "./lexicons/index.ts"; 5 + import { CDN_URL } from "../env.ts"; 5 6 6 7 export class EventHandler { 7 8 private streamerClients: Map<string, Set<WebSocket>>; ··· 55 56 record.streamer, 56 57 ); 57 58 if (subscribedClients && subscribedClients.size > 0) { 58 - const enrichedMessage = await this.enrichMessage(event); 59 + const enrichedMessage = await this.enrichMessage( 60 + record.streamer, 61 + event, 62 + ); 59 63 const messageJson = JSON.stringify({ 60 64 type: "chat_message", 61 65 data: enrichedMessage, ··· 79 83 } 80 84 81 85 private async enrichMessage( 86 + streamerDid: Did, 82 87 commitEvent: CommitEvent, 83 88 ): Promise<EnrichedChatMessage> { 84 - const profile = await didResolver.resolve(commitEvent.did); 85 - 86 89 // TODO: Not handling deletes for now 87 90 if (commitEvent.commit.operation === "delete") throw new Error(); 88 91 92 + const { handle, pdsEndpoint, actorProfile, chatProfile } = 93 + await didResolver.resolve(commitEvent.did); 94 + // Get moderators for badge creation 95 + const moderators = botInstances.get(streamerDid)?.getModerators(); 96 + const badges: BadgeView[] = []; 97 + if (streamerDid === commitEvent.did) { 98 + badges.push({ 99 + badgeType: "place.stream.badge.defs#streamer", 100 + issuer: "did:web:stream.place", 101 + recipient: commitEvent.did, 102 + }); 103 + } 104 + if (chatProfile?.selfLabels?.includes("bot")) { 105 + badges.push({ 106 + badgeType: "place.stream.badge.defs#bot", 107 + issuer: "did:web:stream.place", 108 + recipient: commitEvent.did, 109 + }); 110 + } 111 + if (moderators && moderators.includes(commitEvent.did)) { 112 + badges.push({ 113 + badgeType: "place.stream.badge.defs#mod", 114 + issuer: "did:web:stream.place", 115 + recipient: commitEvent.did, 116 + }); 117 + } 118 + 119 + interface BlobWithRef { 120 + $type: string; 121 + ref: { 122 + $link: string; 123 + }; 124 + mimeType?: string; 125 + size?: number; 126 + } 127 + 89 128 return { 90 129 service: "streamplace", 91 130 author: { 92 - handle: profile.handle, 131 + handle: handle, 93 132 did: commitEvent.did, 94 - pdsEndpoint: profile.pdsEndpoint, 95 - description: profile.description, 96 - displayName: profile.displayName, 97 - pronouns: profile.pronouns, 98 - website: profile.website, 99 - avatarUrl: profile.avatarUrl, 100 - bannerUrl: profile.bannerUrl, 133 + pdsEndpoint: pdsEndpoint, 134 + description: actorProfile?.description, 135 + displayName: actorProfile?.displayName, 136 + pronouns: actorProfile?.pronouns, 137 + website: actorProfile?.website, 138 + avatarUrl: actorProfile?.avatar 139 + ? this.imageUrl( 140 + commitEvent.did, 141 + (actorProfile.avatar as BlobWithRef).ref.$link, 142 + ) 143 + : undefined, 144 + bannerUrl: actorProfile?.banner 145 + ? this.imageUrl( 146 + commitEvent.did, 147 + (actorProfile.banner as BlobWithRef).ref.$link, 148 + ) 149 + : undefined, 101 150 }, 102 - badges: [], //TODO 151 + badges: badges, 103 152 chatProfile: { 104 153 $type: "place.stream.chat.profile", 105 - color: profile.color, 106 - selfLabels: [], //TODO 154 + color: chatProfile?.color, 155 + selfLabels: chatProfile?.selfLabels, 107 156 }, 108 - cid: commitEvent.commit.cid, //TODO 157 + cid: commitEvent.commit.cid, 109 158 deleted: false, 110 - indexedAt: "", //TODO. 111 - record: commitEvent.commit.record as PlaceStreamChatMessage.Main, //TODO 159 + indexedAt: new Date().toISOString(), // could convert the `time_us` but this is good enough 160 + record: commitEvent.commit.record as PlaceStreamChatMessage.Main, 112 161 uri: `at://${commitEvent.did}/place.stream.chat.message/${commitEvent.commit.rkey}`, 113 162 }; 163 + } 164 + 165 + // Helper function 166 + private imageUrl(did: Did, link: string): string { 167 + return `${CDN_URL}/feed_thumbnail/plain/${did}/${link}@jpeg`; 114 168 } 115 169 }
+11 -14
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 + declare type Facet = 4 + import("./lexicons/index.ts").PlaceStreamRichtextFacet.Main; 4 5 declare type MessageView = 5 6 import("./lexicons/index.ts").PlaceStreamChatDefs.MessageView; 7 + declare type BadgeView = 8 + import("./lexicons/index.ts").PlaceStreamBadgeDefs.BadgeView; 9 + declare type ActorProfile = import("@atcute/bluesky").AppBskyActorProfile.Main; 10 + declare type ChatProfile = 11 + import("./lexicons/index.ts").PlaceStreamChatProfile.Main; 6 12 7 - // Handle + app.bsky.actor.profile + place.stream.chat.profile 8 - // + pronouns from labeler for maximum info about chatter 13 + // Maximal information about a chatter 9 14 interface UserProfile { 10 15 handle: Handle; 11 16 pdsEndpoint: string; 12 - color?: { 13 - red: number; 14 - green: number; 15 - blue: number; 16 - }; 17 - avatarUrl?: string; 18 - bannerUrl?: string; 19 - website?: string; 20 - pronouns?: string; 21 - description?: string; 22 - displayName?: string; 17 + actorProfile?: ActorProfile; 18 + chatProfile?: ChatProfile; 23 19 } 24 20 21 + // Enriched chat message that will be passed to the overlay 25 22 interface EnrichedChatMessage extends MessageView { 26 23 service: "streamplace" | "twitch"; 27 24 author: {
+45 -1
utils/streamplaceBot.ts
··· 28 28 private client: AtprotoClient; 29 29 30 30 // Caching 31 + private moderators: Did[] = []; 32 + private moderatorsLoaded: boolean = false; 31 33 private shoutouts: Map<Did, ShoutoutRecord> = new Map(); 32 34 private hasBeenGreeted: Map<Did, boolean> = new Map(); 33 35 private shoutoutsLoaded: boolean = false; ··· 57 59 // Initialize the AT Protocol client 58 60 await this.client.init(); 59 61 60 - // Load shoutouts into cache 62 + // Load moderators, blocks & shoutouts into cache 63 + await this.loadModerators(); 61 64 await this.loadShoutouts(); 62 65 63 66 // Register default commands ··· 67 70 console.log( 68 71 `StreamplaceBot initialized for streamer: ${streamer.handle}`, 69 72 ); 73 + } 74 + 75 + // Load all moderators for the streamer into the cache 76 + private async loadModerators(): Promise<void> { 77 + if (this.moderatorsLoaded) return; 78 + 79 + const streamer = await this.getUserProfile(this.streamerDid); 80 + try { 81 + const moderatorsData = await listRecords( 82 + streamer.pdsEndpoint, 83 + { 84 + repo: this.streamerDid, 85 + collection: "place.stream.moderation.permission", 86 + }, 87 + ); 88 + 89 + // Cache all moderators 90 + for (const record of moderatorsData.records) { 91 + const userDid = record.value.moderator as Did; 92 + this.moderators.push(userDid); 93 + 94 + // Also resolve and cache shoutoutees 95 + await this.getUserProfile(userDid); 96 + } 97 + 98 + this.moderatorsLoaded = true; 99 + console.log(`Loaded ${streamer.handle}'s moderators into cache`); 100 + } catch (error) { 101 + console.error("Error loading shoutouts:", error); 102 + } 70 103 } 71 104 72 105 // Load all shoutouts for the streamer into the cache ··· 179 212 } 180 213 } 181 214 215 + // Reload moderators from the repository 216 + async reloadModerators(): Promise<void> { 217 + this.moderatorsLoaded = false; 218 + this.moderators = []; 219 + await this.loadModerators(); 220 + } 221 + 182 222 // Reload shoutouts from the repository 183 223 async reloadShoutouts(): Promise<void> { 184 224 this.shoutoutsLoaded = false; ··· 204 244 205 245 getCommands(): Map<string, CommandHandler> { 206 246 return this.commands; 247 + } 248 + 249 + getModerators(): Did[] { 250 + return this.moderators; 207 251 } 208 252 209 253 getShoutout(did: Did): ShoutoutRecord | undefined {