Schedule posts to Bluesky with Cloudflare workers.
skyscheduler.work
cf
tool
bsky-tool
cloudflare
bluesky
schedule
bsky
service
social-media
cloudflare-workers
1const repostForm = document.getElementById("repostForm");
2const repostTitle = document.getElementById("repostTitle");
3const repostTitleSection = document.getElementById("repostTitleSection");
4const repostRecordURL = document.getElementById("repostRecordURL");
5
6async function getAccountHandle(account) {
7 if (account.match(/did\:plc\:/i)) {
8 return account;
9 }
10 const lookupRequest = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${account}`);
11 if (lookupRequest.ok) {
12 const response = await lookupRequest.json();
13 if (response.hasOwnProperty("did")) {
14 return response.did;
15 }
16 }
17 return null;
18}
19
20async function getPostCID(account, postid) {
21 const cidResponse = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=${account}&rkey=${postid}`);
22 if (cidResponse.ok) {
23 const response = await cidResponse.json();
24 if (response.hasOwnProperty("cid"))
25 return response.cid;
26 }
27 return null;
28}
29
30document.addEventListener("resetRepost", () => {
31 repostForm.reset();
32 repostForm.removeAttribute("disabled");
33 showRepostProgress(false);
34 setElementVisible(repostTitleSection, true);
35 setElementDisabled(repostTitle, false);
36 repostTitle.value = "";
37 repostRecordURL.value = "";
38 setTimeout(scrollTop, 400);
39});
40
41repostForm.addEventListener('submit', async (e) => {
42 e.preventDefault();
43 showRepostProgress(true);
44 const scheduledDateVal = document.getElementById("repostTime").value;
45 const postRecordVal = repostRecordURL.value;
46 let dateTime;
47 try {
48 dateTime = new Date(scheduledDateVal).toISOString();
49 } catch(dateErr) {
50 pushToast("Invalid date", false);
51 showRepostProgress(false);
52 return;
53 }
54
55 const postObject = {
56 url: postRecordVal,
57 scheduledDate: dateTime
58 };
59
60 // Add repost data if we should be making reposts
61 const repostCycleOptions = document.getElementById("makeRepostOptions");
62 if (repostCycleOptions.checked) {
63 const repostValues = repostCycleOptions.parentElement.querySelectorAll("select");
64 postObject.repostData = {
65 hours: repostValues[0].value,
66 times: repostValues[1].value
67 };
68 }
69
70 // Push any names we have for the post here too
71 if (repostTitle.value !== "" && isElementVisible(repostTitleSection)) {
72 postObject.content = repostTitle.value;
73 }
74
75 const {account, postid} = ATPROTO_RECORD_REGEX.exec(postRecordVal)?.groups;
76 if (account === undefined || postid === undefined) {
77 pushToast("URL provided is invalid!", false);
78 showRepostProgress(false);
79 return;
80 }
81 const didResponse = await getAccountHandle(account);
82 if (didResponse === null) {
83 pushToast("Unable to infer account poster at this time", false);
84 showRepostProgress(false);
85 return;
86 }
87 postObject.uri = `at://${didResponse}/app.bsky.feed.post/${postid}`;
88 const cidFetch = await getPostCID(didResponse, postid);
89 if (cidFetch === null) {
90 pushToast("Unable to infer post records at this time, double check URL", false);
91 showRepostProgress(false);
92 return;
93 }
94 postObject.cid = cidFetch;
95
96 const payload = JSON.stringify(postObject);
97 const response = await fetch('/post/create/repost', {
98 method: 'POST',
99 headers: {'Content-Type': 'application/json' },
100 body: payload
101 });
102 const data = await response.json();
103
104 if (response.ok) {
105 pushToast(data.msg, true);
106 document.dispatchEvent(new Event("resetRepost"));
107 refreshPosts();
108 } else {
109 // For postnow, we try again, immediate failures still add to the DB
110 if (response.status === 406 && postNow) {
111 document.dispatchEvent(new Event("resetRepost"));
112 htmx.trigger("body", "accountViolations");
113 refreshPosts();
114 }
115 pushToast(translateErrorObject(data, data.error?.message || data.error || "An Error Occurred"), false);
116 showRepostProgress(false);
117 }
118});
119
120document.addEventListener("addNewRepost", (ev) => {
121 const postHeader = ev.detail.target;
122 const postURIHolder = postHeader.parentElement.querySelector("footer small a:not(hidden)");
123 const postBodyHolder = postHeader.parentElement.querySelector(".postText");
124 if (postURIHolder && postBodyHolder) {
125 setElementVisible(repostTitleSection, true);
126 setElementDisabled(repostTitle, false);
127 const postURI = postURIHolder.getAttribute("href");
128 const isExistingRepost = postHeader.hasAttribute("data-repost");
129 if (isExistingRepost) {
130 repostTitle.value = postBodyHolder.innerText.trim();
131 } else {
132 setElementVisible(repostTitleSection, false);
133 setElementDisabled(repostTitle, true);
134 repostTitle.value = "";
135 }
136 repostRecordURL.value = postURI;
137 if (contentTabs !== null) {
138 contentTabs.switchTab("dashtabs", 1);
139 scrollToObject(repostRecordURL);
140 document.getElementById("repostTime").value = getScheduleTimeForNextHour();
141 return;
142 }
143 }
144 pushToast("cannot add reposts to this post", false);
145});
146
147
148function showRepostProgress(shouldShow) {
149 const el = document.getElementById("makingRepostRequest");
150 el.setAttribute("aria-busy", shouldShow);
151 setElementDisabled(el, shouldShow);
152 setElementDisabled(postForm, shouldShow);
153 if (shouldShow) {
154 el.textContent = "Scheduling Retweets...";
155 } else {
156 el.textContent = "Schedule Retweet";
157 }
158}