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.

Overlay now works for multiple streamers at once and also with DIDs

+170 -88
+2 -2
README.md
··· 15 15 ## Advanced usage 16 16 - Set up custom commands in your PDS based on [this lexicon](https://pds.ls/at://did:plc:xnpibbj2hcqlsodutcnirmxp/com.atproto.lexicon.schema/online.timtinkers.bot.command). You can find examples [here](https://pds.ls/at://did:plc:xnpibbj2hcqlsodutcnirmxp/online.timtinkers.bot.command) (work-in-progress) 17 17 - Set up custom shoutouts in your PDS based on [this lexicon](https://pds.ls/at://did:plc:xnpibbj2hcqlsodutcnirmxp/com.atproto.lexicon.schema/online.timtinkers.bot.shoutout). You can find my current shoutouts [here](https://pds.ls/at://did:plc:o6xucog6fghiyrvp7pyqxcs3/online.timtinkers.bot.shoutout) 18 - - Navigate to your chat overlay at https://bot.timtinkers.online/chat/alice.bsky.social or http://localhost:8000/chat/alice.bsky.social if you are self-hosting 19 - - If you want to build your own overlay and still want to use enriched messages (that include avatars, pronouns, banners, PDS hosts), you can also subscribe to the websocket at wss://bot.timtinkers.online/api/websocket/alice.bsky.social or ws://localhost:8000/api/websocket/alice.bsky.social respectively 18 + - Navigate to your chat overlay at https://bot.timtinkers.online/chat?streamer=alice.bsky.social or http://localhost:8000/chat?streamer=alice.bsky.social if you are self-hosting 19 + - If you want to build your own overlay and still want to use enriched messages (that include avatars, pronouns, banners, PDS hosts), you can also subscribe to the websocket at wss://bot.timtinkers.online/api/websocket?streamer=alice.bsky.social or ws://localhost:8000/api/websocket?streamer=alice.bsky.social respectively 20 20 21 21 ## Setup for self-hosters 22 22
+14 -1
islands/ChatMessage.tsx
··· 16 16 interface ChatMessageProps { 17 17 data: ChatMessageData; 18 18 onExpire?: (id: string) => void; 19 + showStreamer?: boolean; 19 20 } 20 21 21 22 // --------------------------------------------------------------------------- ··· 135 136 // Main component 136 137 // --------------------------------------------------------------------------- 137 138 138 - export default function ChatMessage({ data, onExpire }: ChatMessageProps) { 139 + export default function ChatMessage( 140 + { data, onExpire, showStreamer }: ChatMessageProps, 141 + ) { 139 142 const [isFadingOut, setIsFadingOut] = useState(false); 140 143 141 144 useEffect(() => { ··· 161 164 const displayName = author.displayName || author.handle; 162 165 const avatarUrl = author.avatarUrl; 163 166 const pronouns = author.pronouns; 167 + 168 + const streamerAvatarUrl = 169 + (data as { streamerAvatarUrl?: string }).streamerAvatarUrl; 164 170 165 171 const chatProfile = data.chatProfile as { color?: RGB } | undefined; 166 172 const color = chatProfile?.color; ··· 204 210 alt="Streamplace" 205 211 class="cm-service-icon" 206 212 /> 213 + {showStreamer && streamerAvatarUrl && ( 214 + <img 215 + src={streamerAvatarUrl} 216 + alt="Streamer" 217 + class="cm-streamer-icon" 218 + /> 219 + )} 207 220 <span 208 221 class="cm-name-chip" 209 222 style={{ background: pillBg, color: pillFg }}
+10 -5
islands/ChatOverlay.tsx
··· 2 2 import ChatMessage from "./ChatMessage.tsx"; 3 3 4 4 interface ChatBoxProps { 5 - streamerHandle: string; 5 + streamers: string[]; 6 6 maxMessages?: number; 7 7 backgroundColor?: string; 8 8 borderColor?: string; ··· 12 12 } 13 13 14 14 export default function ChatBox({ 15 - streamerHandle, 15 + streamers, 16 16 maxMessages = 50, 17 17 backgroundColor = "rgba(0, 0, 0, 0.0)", 18 18 borderColor = "rgba(0, 0, 0, 0.0)", ··· 108 108 const protocol = globalThis.location.protocol === "https:" 109 109 ? "wss:" 110 110 : "ws:"; 111 - const wsUrl = 112 - `${protocol}//${globalThis.location.host}/api/websocket/${streamerHandle}`; 111 + // In ChatOverlay.tsx 112 + const wsUrl = `${protocol}//${globalThis.location.host}/api/websocket?${ 113 + streamers.map((s) => `streamer=${encodeURIComponent(s)}`).join("&") 114 + }`; 113 115 114 116 function connect() { 115 117 if (!isMountedRef.current) return; ··· 184 186 wsRef.current.close(); 185 187 } 186 188 }; 187 - }, [isClient, streamerHandle, handleAddMessage]); 189 + }, [isClient, streamers, handleAddMessage]); 190 + 191 + const showStreamer = streamers.length > 1; 188 192 189 193 return ( 190 194 <div ··· 202 206 key={message.uri} 203 207 data={message} 204 208 onExpire={handleExpire} 209 + showStreamer={showStreamer} 205 210 /> 206 211 ))} 207 212 </div>
-62
routes/api/websocket/[streamerHandle].ts
··· 1 - import { define } from "../../../utils.ts"; 2 - import { streamplaceWS } from "../../../utils/websocket.ts"; 3 - import { resolveHandle } from "../../../utils/atcuteUtils.ts"; 4 - 5 - interface StreamplaceWebSocket extends WebSocket { 6 - streamerDid: Did; 7 - } 8 - 9 - export const handler = define.handlers({ 10 - async GET(ctx) { 11 - const { streamerHandle } = ctx.params; 12 - 13 - if (ctx.req.headers.get("upgrade") !== "websocket") { 14 - return new Response("WebSocket connections only", { status: 426 }); 15 - } 16 - 17 - try { 18 - const { socket, response } = Deno.upgradeWebSocket(ctx.req); 19 - const streamerDid = await resolveHandle(streamerHandle as Handle); 20 - 21 - // Cast the socket to our extended type 22 - const spSocket = socket as StreamplaceWebSocket; 23 - 24 - spSocket.onopen = () => { 25 - console.log( 26 - `✅ New websocket client connected for streamer: ${streamerHandle}`, 27 - ); 28 - spSocket.streamerDid = streamerDid; 29 - streamplaceWS.handleNewConnection(spSocket, streamerDid); 30 - }; 31 - 32 - spSocket.onmessage = (event) => { 33 - try { 34 - console.log(`Received client message: ${event.data}`); 35 - const message = JSON.parse(event.data); 36 - streamplaceWS.handleClientMessage(spSocket, message); 37 - } catch (error) { 38 - console.error("Error handling client message:", error); 39 - spSocket.send( 40 - JSON.stringify({ error: "Invalid message format" }), 41 - ); 42 - } 43 - }; 44 - 45 - spSocket.onclose = () => { 46 - console.log( 47 - `❎ Websocket client disconnected for streamer: ${streamerHandle}`, 48 - ); 49 - streamplaceWS.removeClient(spSocket); 50 - }; 51 - 52 - spSocket.onerror = (error) => { 53 - console.error("WebSocket error:", error); 54 - }; 55 - 56 - return response; 57 - } catch (error) { 58 - console.error("Error upgrading WebSocket:", error); 59 - return new Response("Failed to upgrade WebSocket", { status: 500 }); 60 - } 61 - }, 62 - });
+94
routes/api/websocket/index.ts
··· 1 + import { define } from "../../../utils.ts"; 2 + import { streamplaceWS } from "../../../utils/websocket.ts"; 3 + import { resolveHandle } from "../../../utils/atcuteUtils.ts"; 4 + import { isDid, isHandle } from "@atcute/lexicons/syntax"; 5 + 6 + export const handler = define.handlers({ 7 + async GET(ctx) { 8 + if (ctx.req.headers.get("upgrade") !== "websocket") { 9 + return new Response("WebSocket connections only", { status: 426 }); 10 + } 11 + 12 + const url = new URL(ctx.req.url); 13 + const streamerParams = url.searchParams.getAll("streamer"); 14 + 15 + if (streamerParams.length === 0) { 16 + return new Response( 17 + "At least one ?streamer= query parameter is required", 18 + { status: 400 }, 19 + ); 20 + } 21 + 22 + // Resolve all streamers to DIDs, accepting both handles and DIDs 23 + let streamerDids: Did[]; 24 + try { 25 + streamerDids = await Promise.all( 26 + streamerParams.map((streamer) => { 27 + if (isDid(streamer)) { 28 + return streamer as Did; 29 + } else if (isHandle(streamer)) { 30 + return resolveHandle(streamer as Handle); 31 + } else { 32 + throw new Error( 33 + `Invalid streamer identifier: "${streamer}" is neither a valid handle nor a DID`, 34 + ); 35 + } 36 + }), 37 + ); 38 + } catch (error) { 39 + return new Response( 40 + error instanceof Error ? error.message : "Invalid streamer identifier", 41 + { status: 400 }, 42 + ); 43 + } 44 + 45 + // Deduplicate DIDs in case the same streamer was passed twice 46 + const uniqueDids = [...new Set(streamerDids)]; 47 + 48 + try { 49 + const { socket, response } = Deno.upgradeWebSocket(ctx.req); 50 + 51 + socket.onopen = () => { 52 + console.log( 53 + `✅ New websocket client connected for streamers: ${uniqueDids.join(", ")}`, 54 + ); 55 + streamplaceWS.initClient(socket); 56 + for (const did of uniqueDids) { 57 + streamplaceWS.handleClientMessage(socket, { 58 + type: "subscribe", 59 + streamer: did, 60 + }); 61 + } 62 + }; 63 + 64 + socket.onmessage = (event) => { 65 + try { 66 + console.log(`Received client message: ${event.data}`); 67 + const message = JSON.parse(event.data); 68 + streamplaceWS.handleClientMessage(socket, message); 69 + } catch (error) { 70 + console.error("Error handling client message:", error); 71 + socket.send( 72 + JSON.stringify({ error: "Invalid message format" }), 73 + ); 74 + } 75 + }; 76 + 77 + socket.onclose = () => { 78 + console.log( 79 + `❎ Websocket client disconnected (was subscribed to: ${uniqueDids.join(", ")})`, 80 + ); 81 + streamplaceWS.removeClient(socket); 82 + }; 83 + 84 + socket.onerror = (error) => { 85 + console.error("WebSocket error:", error); 86 + }; 87 + 88 + return response; 89 + } catch (error) { 90 + console.error("Error upgrading WebSocket:", error); 91 + return new Response("Failed to upgrade WebSocket", { status: 500 }); 92 + } 93 + }, 94 + });
-15
routes/chat/[streamerHandle].tsx
··· 1 - import { define } from "../../utils.ts"; 2 - import ChatOverlay from "../../islands/ChatOverlay.tsx"; 3 - 4 - export default define.page(function ChatPage(ctx) { 5 - const { streamerHandle } = ctx.params; 6 - 7 - return ( 8 - <div> 9 - <ChatOverlay 10 - streamerHandle={streamerHandle as Handle} 11 - maxMessages={50} 12 - /> 13 - </div> 14 - ); 15 - });
+20
routes/chat/index.tsx
··· 1 + import { define } from "../../utils.ts"; 2 + import ChatOverlay from "../../islands/ChatOverlay.tsx"; 3 + 4 + export default define.page(function ChatPage(ctx) { 5 + const url = new URL(ctx.req.url); 6 + const streamers = url.searchParams.getAll("streamer"); 7 + 8 + if (streamers.length === 0) { 9 + return <div>No streamers specified.</div>; 10 + } 11 + 12 + return ( 13 + <div> 14 + <ChatOverlay 15 + streamers={streamers} 16 + maxMessages={50} 17 + /> 18 + </div> 19 + ); 20 + });
+15
static/chat-message.css
··· 115 115 object-fit: contain; 116 116 flex-shrink: 0; 117 117 filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); 118 + position: relative; 119 + z-index: 1; 120 + } 121 + 122 + /* Streamer avatar icon — same size, sits just behind the service icon */ 123 + .cm-streamer-icon { 124 + width: 16px; 125 + height: 16px; 126 + border-radius: 4px; 127 + object-fit: cover; 128 + flex-shrink: 0; 129 + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); 130 + margin-left: -6px; 131 + position: relative; 132 + z-index: 0; 118 133 } 119 134 120 135 /* Name chip */
+9
utils/eventHandler.ts
··· 302 302 303 303 const { handle, pdsEndpoint, actorProfile, chatProfile } = 304 304 await didResolver.resolve(commitEvent.did); 305 + // Get streamer avatar 306 + const streamerAvatar = (await didResolver.resolve(streamerDid)) 307 + .actorProfile?.avatar; 305 308 // Get moderators for badge creation 306 309 const moderators = botInstances.get(streamerDid)?.getModerators(); 307 310 const badges: BadgeView[] = []; ··· 367 370 }, 368 371 cid: commitEvent.commit.cid, 369 372 deleted: false, 373 + streamerAvatarUrl: streamerAvatar 374 + ? this.imageUrl( 375 + streamerDid, 376 + (streamerAvatar as BlobWithRef).ref.$link, 377 + ) 378 + : undefined, 370 379 indexedAt: new Date().toISOString(), // could convert the `time_us` but this is good enough 371 380 record: commitEvent.commit.record as PlaceStreamChatMessage.Main, 372 381 uri: `at://${commitEvent.did}/place.stream.chat.message/${commitEvent.commit.rkey}`,
+1
utils/globals.d.ts
··· 34 34 }; 35 35 // it is not obvious to me why but the official lexicon has type Record<string, unknown> here 36 36 record: import("./lexicons/index.ts").PlaceStreamChatMessage.Main; 37 + streamerAvatarUrl?: string; 37 38 }
+5 -3
utils/websocket.ts
··· 35 35 this.connectToJetstream(); 36 36 } 37 37 38 - // Handle new connection with pre-defined streamer DID 39 - handleNewConnection(client: WebSocket, streamerDid: Did) { 40 - // Initialize client with empty subscriptions 38 + initClient(client: WebSocket) { 41 39 this.clients.set(client, new Set()); 40 + } 42 41 42 + // Handle new connection with pre-defined streamer DID 43 + handleNewConnection(client: WebSocket, streamerDid: Did) { 44 + this.initClient(client); 43 45 // Auto-subscribe to the streamer from the route parameter 44 46 this.handleClientMessage(client, { 45 47 type: "subscribe",