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
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}