Firebot Custom Script that creates a app.bsky.actor.status record to "go live" on Bluesky, i.e. to use at stream start
firebot-bsky-go-live.js edited
313 lines 9.3 kB view raw
1/** 2 * Description: In Firebot Events, add this file to "Run Custom Script" 3 * in an event triggered by "Stream Started". 4 * This script will: 5 * - fetch stream description, title and thumbnail from the OpenGraph meta tags 6 * - create a session with Bluesky 7 * - upload the thumbnail as a blob to Bluesky 8 * - create the app.bsky.actor.status record that enables the live badge 9 */ 10 11/** 12 * Firebot-specific code block below. 13 * You will have to change this if you are using a different bot. 14 */ 15exports.run = (runRequest) => { 16 const logger = runRequest.modules.logger; 17 const { 18 BSKY_HANDLE, 19 APP_PASSWORD, 20 PDS_HOST, 21 TWITCH_USERNAME, 22 DURATION, 23 } = runRequest.parameters; 24 goLive( 25 BSKY_HANDLE, 26 APP_PASSWORD, 27 PDS_HOST, 28 TWITCH_USERNAME, 29 DURATION, 30 logger, 31 ); 32}; 33exports.getScriptManifest = function () { 34 return { 35 name: "Go Live on Bluesky", 36 description: 37 "Creates the app.bsky.actor.status record via API that enables the live badge on Bluesky.", 38 author: "timtinkers.online", 39 version: "1.1.0", 40 website: "https://tangled.org/timtinkers.online/", 41 startupOnly: false, 42 firebotVersion: "5", 43 }; 44}; 45exports.getDefaultParameters = function getDefaultParameters() { 46 return new Promise((resolve, reject) => { 47 resolve({ 48 BSKY_HANDLE: { 49 "type": "string", 50 "description": "Your full Bluesky handle", 51 "default": "alice.bsky.social", 52 }, 53 APP_PASSWORD: { 54 "type": "string", 55 "description": 56 "App-password created at https://bsky.app/settings/app-passwords.\nNEVER share your app password with anyone else, it gives full API access to your account!", 57 "default": "xxxx-xxxx-xxxx-xxxx", 58 }, 59 PDS_HOST: { 60 "type": "string", 61 "description": 62 "URL of your host Personal Data Server, do not change if you don't know what that is!", 63 "default": "https://bsky.social", 64 }, 65 TWITCH_USERNAME: { 66 "type": "string", 67 "description": "Your Twitch username, that will be linked in the badge", 68 "default": "twitchdev", 69 }, 70 DURATION: { 71 "type": "number", 72 "description": 73 "Duration in minutes you expect to be live for, maximum 240 minutes", 74 "default": 240, 75 }, 76 }); 77 }); 78}; 79 80/** 81 * Extracts OpenGraph data from the HTML document of the Twitch URL 82 */ 83async function extractOpenGraphData(url, logger) { 84 try { 85 logger.info(`Fetching OpenGraph data from: ${url}`); 86 const response = await fetch(url, { 87 // Setting a user agent so Twitch doesn't spend time loading JS 88 headers: { 89 "User-Agent": 90 "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", 91 }, 92 }); 93 94 if (!response.ok) { 95 logger.error( 96 `Failed to fetch Twitch page: ${response.status} ${response.statusText}`, 97 ); 98 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 99 } 100 101 const html = await response.text(); 102 103 const getMetaContent = (property) => { 104 // Try property/name first, then content 105 const regex1 = new RegExp( 106 `<meta\\s+(?:property|name)="${property}"\\s+content="([^"]*)"`, 107 "i", 108 ); 109 // Try content first, then property/name 110 const regex2 = new RegExp( 111 `<meta\\s+content="([^"]*)"\\s+(?:property|name)="${property}"`, 112 "i", 113 ); 114 115 const match = html.match(regex1) || html.match(regex2); 116 return match ? match[1] : null; 117 }; 118 119 const ogData = { 120 title: getMetaContent("og:title"), 121 description: getMetaContent("og:description"), 122 image: getMetaContent("og:image"), 123 }; 124 125 logger.debug(`OpenGraph data extracted: ${JSON.stringify(ogData)}`); 126 return ogData; 127 } catch (error) { 128 logger.error(`Error extracting OpenGraph data: ${error.message}`); 129 throw error; 130 } 131} 132 133/** 134 * Creates the app.bsky.actor.status record via API 135 */ 136async function goLive( 137 handle, 138 password, 139 pdsHost, 140 twitchUsername, 141 duration, 142 logger, 143) { 144 try { 145 logger.info("Starting Bluesky live badge creation"); 146 147 // 1. Extract OpenGraph data from Twitch URL 148 const twitchUrl = `https://www.twitch.tv/${twitchUsername}`; 149 const ogData = await extractOpenGraphData(twitchUrl, logger); 150 151 // 2. Create session 152 logger.info("Authenticating with Bluesky"); 153 const createSession = await fetch( 154 pdsHost + "/xrpc/com.atproto.server.createSession", 155 { 156 method: "POST", 157 headers: { "Content-Type": "application/json" }, 158 body: JSON.stringify({ identifier: handle, password: password }), 159 }, 160 ); 161 162 if (!createSession.ok) { 163 const errorText = await createSession.text(); 164 logger.error( 165 `Bluesky authentication failed: ${createSession.status} ${createSession.statusText}`, 166 ); 167 logger.error(`Error details: ${errorText}`); 168 throw new Error(`Auth failed: ${createSession.status}`); 169 } 170 171 const { did, accessJwt } = await createSession.json(); 172 logger.debug(`Authenticated successfully. DID: ${did}`); 173 174 // 3. Upload thumbnail blob 175 let blob; 176 if (ogData.image) { 177 try { 178 logger.info(`Uploading thumbnail: ${ogData.image}`); 179 180 // First, fetch the image from the URL 181 const imageResponse = await fetch(ogData.image); 182 if (!imageResponse.ok) { 183 logger.error( 184 `Failed to fetch thumbnail: ${imageResponse.status} ${imageResponse.statusText}`, 185 ); 186 throw new Error(`Image fetch failed: ${imageResponse.status}`); 187 } 188 189 const imageBlob = await imageResponse.blob(); 190 logger.debug( 191 `Thumbnail fetched, size: ${imageBlob.size} bytes, type: ${imageBlob.type}`, 192 ); 193 194 // Then upload it to Bluesky 195 const blobRes = await fetch( 196 pdsHost + "/xrpc/com.atproto.repo.uploadBlob", 197 { 198 method: "POST", 199 headers: { 200 "Content-Type": imageBlob.type, 201 Authorization: `Bearer ${accessJwt}`, 202 }, 203 body: imageBlob, 204 }, 205 ); 206 207 if (!blobRes.ok) { 208 const errorText = await blobRes.text(); 209 logger.error( 210 `Blob upload failed: ${blobRes.status} ${blobRes.statusText}`, 211 ); 212 logger.error(`Error details: ${errorText}`); 213 throw new Error(`Blob upload failed: ${blobRes.status}`); 214 } 215 216 const blobData = await blobRes.json(); 217 blob = blobData.blob; 218 logger.info("Thumbnail uploaded successfully"); 219 } catch (error) { 220 logger.error(`Error during thumbnail upload: ${error.message}`); 221 logger.info("Continuing without thumbnail"); 222 blob = null; 223 } 224 } else { 225 logger.info("No thumbnail found in OpenGraph data"); 226 } 227 228 // 4. Create the live actor status record 229 logger.info("Creating live status record"); 230 231 // Check if app.bsky.actor.status exists 232 const writes = []; 233 const getRecordRes = await fetch( 234 `${pdsHost}/xrpc/com.atproto.repo.getRecord?repo=${ 235 encodeURIComponent(did) 236 }&collection=app.bsky.actor.status&rkey=self`, 237 { 238 method: "GET", 239 headers: { 240 "Content-Type": "application/json", 241 }, 242 }, 243 ); 244 // If exists, queue delete 245 if (getRecordRes.ok) { 246 writes.push({ 247 $type: "com.atproto.repo.applyWrites#delete", 248 collection: "app.bsky.actor.status", 249 rkey: "self", 250 }); 251 } 252 253 // Set up new app.bsky.actor.status 254 const record = { 255 $type: "app.bsky.actor.status", 256 embed: { 257 $type: "app.bsky.embed.external", 258 external: { 259 uri: twitchUrl, 260 $type: "app.bsky.embed.external#external", 261 thumb: blob || {}, 262 title: ogData.title || `${twitchUsername} - Twitch`, 263 description: ogData.description || 264 `${twitchUsername} streams live on Twitch!`, 265 }, 266 }, 267 status: "app.bsky.actor.status#live", 268 createdAt: new Date().toISOString(), 269 durationMinutes: typeof duration === "number" 270 ? Math.max(10, Math.min(240, Math.round(duration))) 271 : 240, 272 }; 273 // Queue create 274 writes.push({ 275 $type: "com.atproto.repo.applyWrites#create", 276 collection: "app.bsky.actor.status", 277 rkey: "self", 278 value: record, 279 }); 280 281 const postRes = await fetch( 282 pdsHost + "/xrpc/com.atproto.repo.applyWrites", 283 { 284 method: "POST", 285 headers: { 286 "Content-Type": "application/json", 287 Authorization: `Bearer ${accessJwt}`, 288 }, 289 body: JSON.stringify({ 290 repo: did, 291 writes: writes, 292 }), 293 }, 294 ); 295 296 if (!postRes.ok) { 297 const errorText = await postRes.text(); 298 logger.error( 299 `Failed to create live status: ${postRes.status} ${postRes.statusText}`, 300 ); 301 logger.error(`Error details: ${errorText}`); 302 throw new Error(`Post failed: ${postRes.status}`); 303 } 304 305 const result = await postRes.json(); 306 logger.info(`Live badge created successfully! URI: ${result.uri}`); 307 return result; 308 } catch (error) { 309 logger.error(`Fatal error in goLive: ${error.message}`); 310 logger.error(`Stack trace: ${error.stack}`); 311 throw error; 312 } 313}