···1414- **Multiple user/account handling**: Manage multiple users/bsky accounts easily
1515- **Bluesky Post Scheduling**: Schedule multiple posts to your Bluesky account
1616- **Hourly Time Slots**: Time selection is limited to hourly intervals to optimize worker execution and reduce unnecessary runs
1717+- **Post Threading**: Schedule entire post threads with full media support per post!
1718- **Simple Setup**: Fairly minimal setup and easy to use
1819- **Supports media posts**: Automatically handles content tagging and formatting your media so that it looks the best on BSky. Image transforms via Cloudflare Images
1920- **Handles Link Embeds**: Post your content with a link embed easily!
···27282829- Node.js (v24.x or later)
2930- Package Manager
3030-- Cloudflare Pro Workers account (for CPU and Queues [can be disabled with `QUEUE_SETTINGS.enabled` set to false])
3131+- Cloudflare Pro Workers account (for CPU)
31323233### Installation
3334···5354 - `TURNSTILE_PUBLIC_KEY` - the turnstile public key for captcha
5455 - `TURNSTILE_SECRET_KEY` - the turnstile secret key for captcha
5556 - `RESIZE_SECRET_HEADER` - a header value that will be included on requests while trying to resize images. Protects the resize bucket while still making it accessible to CF Images.
5656-5757+5758**Note**: When deploying, these variables should also be configured as secrets in your Cloudflare worker dashboard. You can also do this via `npx wrangler secret put <NAME_OF_SECRET>`.
585959604. Update your `wrangler.toml` with changes that reflect your account.
···1010import { createDMWithUser } from "../utils/bskyMsg";
11111212function createPasswordResetMessage(url: string) {
1313- return `Your SkyScheduler password reset url is:
1414-${url}
1313+ return `Your SkyScheduler password reset url is:
1414+${url}
15151616This URL will expire in about an hour.
1717···134134 enabled: false
135135 },
136136 },
137137- telemetry: {
137137+ telemetry: {
138138 enabled: false
139139 },
140140 logger: {
+12-6
src/db/app.schema.ts
···1818 cid: text('cid'),
1919 // if this post is a pseudo post (i.e. a repost of anything)
2020 isRepost: integer('isRepost', { mode: 'boolean' }).default(false),
2121- // if this post has a post chain to it, this should only ever apply to the root post
2222- //isThread: integer('isThread', {mode: 'boolean'}).default(false),
2121+ rootPost: text('rootPost'),
2222+ parentPost: text('parentPost'),
2323+ threadOrder: integer('threadOrder').default(-1),
2324 // bsky content labels
2425 contentLabel: text('contentLabel', {mode: 'text'}).$type<PostLabel>().default(PostLabel.None).notNull(),
2526 // metadata timestamps
···4546 .where(sql`isRepost = 1`),
4647 // for db pruning and parity with the PDS
4748 index("postedUUID_idx").on(table.uuid, table.posted),
4848- // for checking for post chains
4949- /*index("threadUUID_idx")
5050- .on(table.uuid, table.isThread, table.rootPost)
5151- .where(sql`isThread = 1`),*/
4949+ // Querying children
5050+ index("generalThread_idx")
5151+ .on(table.parentPost, table.rootPost)
5252+ .where(sql`parentPost is not NULL`),
5353+ // Updating thread orders
5454+ index("threadOrder_idx")
5555+ .on(table.rootPost, table.threadOrder)
5656+ .where(sql`threadOrder >= 0`),
5257 // cron job
5358 index("postNowScheduledDatePosted_idx")
5459 .on(table.posted, table.scheduledDate, table.postNow)
···115120 .notNull(),
116121});
117122123123+// helper bookkeeping to make sure we don't have a ton of abandoned files in R2
118124export const mediaFiles = sqliteTable('media', {
119125 fileName: text('file', {mode: 'text'}).primaryKey(),
120126 hasPost: integer('hasPost', { mode: 'boolean' }).default(false),
···2222 </p>
2323 <br />
2424 <section>
2525- <form id="settingsData" name="settingsData" hx-post="/account/update" hx-target="#accountResponse"
2525+ <form id="settingsData" name="settingsData" hx-post="/account/update" hx-target="#accountResponse"
2626 hx-swap="innerHTML swap:1s" hx-indicator="#spinner" hx-disabled-elt="#settingsButtons button, find input" novalidate>
27272828 <UsernameField required={false} title="BlueSky Handle:" hintText="Only change this if you have recently changed your Bluesky handle" />
29293030 <label>
3131- Dashboard Pass:
3131+ Dashboard Pass:
3232 <DashboardPasswordField autocomplete={PWAutoCompleteSettings.CurrentPass} />
3333 <small>The password to access the SkyScheduler Dashboard</small>
3434 </label>
3535 <label>
3636- BSky App Password:
3636+ BSky App Password:
3737 <BSkyAppPasswordField />
3838 <small>If you need to change your bsky application password, you can <a href="https://bsky.app/settings/app-passwords" target="_blank">get a new one here</a>.</small>
3939 </label>
4040 <label>
4141- BSky PDS:
4141+ BSky PDS:
4242 <input type="text" name="bskyUserPDS" placeholder={placeholderPDS} />
4343 <small>If you have not changed your PDS (or do not know what that means), you should leave this blank!</small>
4444 </label>
···5959 <header>Delete Account</header>
6060 <p>To delete your SkyScheduler account, please type your password below.<br />
6161 All pending, scheduled posts + all unposted media will be deleted from this service.
6262-6262+6363 <center><strong>NOTE</strong>: THIS ACTION IS <u>PERMANENT</u>.</center>
6464 </p>
6565 <form id="delAccountForm" name="delAccountForm" hx-post="/account/delete" hx-target="#accountDeleteResponse" hx-disabled-elt="#accountDeleteButtons button, find input"
+2-2
src/layout/violationsBar.tsx
···2020 errorStr = "You currently have media that's too large for Bluesky (like a video), please delete those posts";
2121 }
2222 return (
2323- <div id="violationBar" class="warning-box" hx-trigger="accountViolations from:body"
2323+ <div id="violationBar" class="warning-box" hx-trigger="accountViolations from:body"
2424 hx-swap="outerHTML" hx-get="/account/violations" hx-target="this">
2525 <span class="warning"><b>WARNING</b>: Account error found! {errorStr}</span>
2626 </div>
2727 );
2828 }
2929- return (<div hx-trigger="accountViolations from:body" hidden id="hiddenViolations"
2929+ return (<div hx-trigger="accountViolations from:body" hidden id="hiddenViolations"
3030 hx-get="/account/violations" hx-swap="outerHTML" hx-target="this"></div>);
3131};
+5-3
src/limits.ts
···2233/** APPLICATION CONFIGURATIONS **/
44// minimum length of a post
55-export const MIN_LENGTH: number = 1;
55+export const MIN_LENGTH: number = 1;
66// max amount of times something can be reposted
77export const MAX_REPOST_INTERVAL: number = 15;
88// max amount of time something can be reposted over
···1313export const MAX_GIF_LENGTH: number = 1;
1414// if gifs should be allowed to upload
1515export const GIF_UPLOAD_ALLOWED: boolean = false;
1616+// max posts per thread
1717+export const MAX_POSTS_PER_THREAD: number = 10;
16181719// This is the length of how much we keep in the DB after a post has been made
1820export const MAX_POSTED_LENGTH: number = 50;
···5153// https://github.com/bluesky-social/social-app/blob/b38013a12ff22a3ebd3075baa0d98bc96302a316/src/lib/constants.ts#L63
5254export const MAX_LENGTH: number = 300;
53555454-// Alt text limit via
5656+// Alt text limit via
5557// https://github.com/bluesky-social/social-app/blob/b38013a12ff22a3ebd3075baa0d98bc96302a316/src/lib/constants.ts#L69
5658export const MAX_ALT_TEXT: number = 2000;
57595858-// Image limit values via
6060+// Image limit values via
5961// https://github.com/bluesky-social/social-app/blob/b38013a12ff22a3ebd3075baa0d98bc96302a316/src/lib/constants.ts#L97
6062export const BSKY_IMG_MAX_WIDTH: number = 2000;
6163export const BSKY_IMG_MAX_HEIGHT: number = 2000;
···1010 const ctx: Context = props.c;
1111 const botAccountURL:string = `https://bsky.app/profile/${ctx.env.RESET_BOT_USERNAME}`;
1212 return (
1313- <BaseLayout title="SkyScheduler - Forgot Password"
1313+ <BaseLayout title="SkyScheduler - Forgot Password"
1414 preloads={[...TurnstileCaptchaPreloads(ctx)]}>
1515 <NavTags />
1616- <AccountHandler title="Forgot Password Reset"
1616+ <AccountHandler title="Forgot Password Reset"
1717 submitText="Request Password Reset"
1818- loadingText="Requesting Password Reset..." endpoint="/account/forgot"
1919- successText="Attempted to send DM. If you do not have it, please make sure you are following the account."
1818+ loadingText="Requesting Password Reset..." endpoint="/account/forgot"
1919+ successText="Attempted to send DM. If you do not have it, please make sure you are following the account."
2020 redirect="/login"
2121 customRedirectDelay={2000}
2222 footerHTML={<FooterCopyright />}>
+6-2
src/pages/homepage.tsx
···11import FooterCopyright from "../layout/footer";
22import { BaseLayout } from "../layout/main";
33import NavTags from "../layout/navTags";
44-import { MAX_REPOST_DAYS, MAX_REPOST_IN_HOURS, MAX_REPOST_INTERVAL, R2_FILE_SIZE_LIMIT_IN_MB } from "../limits";
44+import {
55+ MAX_POSTS_PER_THREAD, MAX_REPOST_DAYS, MAX_REPOST_IN_HOURS,
66+ MAX_REPOST_INTERVAL, R2_FILE_SIZE_LIMIT_IN_MB
77+} from "../limits";
5869export default function Home() {
710 return (
···1114 <article>
1215 <noscript><header>Javascript is required to use this website</header></noscript>
1316 <p>
1414- <strong>SkyScheduler</strong> is a free, <a href="https://github.com/socksthewolf/skyscheduler" rel="nofollow" target="_blank">open source</a> service that
1717+ <strong>SkyScheduler</strong> is a free, <a href="https://github.com/socksthewolf/skyscheduler" rel="nofollow" target="_blank">open source</a> service that
1518 lets you schedule and automatically repost your content on Bluesky!<br />
1619 Boost engagement and reach more people no matter what time of day!<br />
1720 <center>
···3437 <li>Schedule your posts any time in the future (to the nearest hour)</li>
3538 <li>Supports embeds, quote posts, links, tagging, mentions</li>
3639 <li>Post <span data-tooltip={`images and video (up to ${R2_FILE_SIZE_LIMIT_IN_MB} MB)`}>media</span> with content labels and full support for alt text</li>
4040+ <li>Schedule entire threads with support of up to {MAX_POSTS_PER_THREAD} posts per thread!</li>
3741 <li>Automatically retweet your content at an interval of your choosing, up to {MAX_REPOST_INTERVAL} times every {MAX_REPOST_IN_HOURS-1} hours (or {MAX_REPOST_DAYS} days)</li>
3842 <li>Edit the content of posts and alt text before they are posted</li>
3943 </ul>
···12121313export default function Signup(props:any) {
1414 const ctx: Context = props.c;
1515- const linkToInvites = isUsingInviteKeys(ctx) ?
1616- (<a href={getInviteThread(ctx)} target="_blank">Invite codes are routinely posted in this thread, grab one here</a>) :
1515+ const linkToInvites = isUsingInviteKeys(ctx) ?
1616+ (<a href={getInviteThread(ctx)} target="_blank">Invite codes are routinely posted in this thread, grab one here</a>) :
1717 "You can ask for the maintainer for it";
18181919 return (
2020- <BaseLayout title="SkyScheduler - Signup"
2020+ <BaseLayout title="SkyScheduler - Signup"
2121 preloads={[...TurnstileCaptchaPreloads(ctx)]}>
2222 <NavTags />
2323- <AccountHandler title="Create an Account"
2323+ <AccountHandler title="Create an Account"
2424 submitText="Sign Up!"
2525- loadingText="Signing up..."
2626- endpoint="/account/signup"
2727- successText="Success! Redirecting to login..."
2525+ loadingText="Signing up..."
2626+ endpoint="/account/signup"
2727+ successText="Success! Redirecting to login..."
2828 redirect="/login"
2929 footerHTML={<FooterCopyright />}>
3030
+3-3
src/pages/tos.tsx
···3232 Deletions may take up to 30 days to fully cycle out of backups.
3333 </p>
3434 <h4>Disclaimer/Limitations</h4>
3535- <p>SkyScheduler IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
3636- IN NO EVENT SHALL THE AUTHORS, HOSTS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
3535+ <p>SkyScheduler IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
3636+ IN NO EVENT SHALL THE AUTHORS, HOSTS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
3737 TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
3838 <h4>Ammendum</h4>
3939- <p>The terms of service may be revised at any time without prior notification.
3939+ <p>The terms of service may be revised at any time without prior notification.
4040 Continued use of the website means that you agree to be bound by the current version of this document.</p>
4141 <footer>
4242 <FooterCopyright />
+7
src/progress.ts
···11+// for the progress bar, this is an easily editable file for updating the bar
22+// maybe we'll support webhooks in the future, but w/e
33+export const PROGRESS_TOTAL: number = 10;
44+export const PROGRESS_MADE: number = 0;
55+66+// if the support bar should be shown or not. Currently is only visible on the dashboard page
77+export const SHOW_PROGRESS_BAR: boolean = false;
···11// Change this value to break out of any caching that might be happening
22// for the runtime scripts (ex: main.js & postHelper.js)
33-export const CURRENT_SCRIPT_VERSION: string = "1.4.3";
33+export const CURRENT_SCRIPT_VERSION: string = "1.4.5";
4455export const getAppScriptStr = (scriptName: string) => `/js/${scriptName}.min.js?v=${CURRENT_SCRIPT_VERSION}`;
6677// Eventually make this automatically generated.
88export const postHelperScriptStr: string = getAppScriptStr("postHelper");
99export const repostHelperScriptStr: string = getAppScriptStr("repostHelper");
1010+export const appScriptStr: string = getAppScriptStr("app");
1111+export const altTextScriptStr: string = getAppScriptStr("altTextHelper");
1212+export const tributeScriptStr: string = getAppScriptStr("tributeHelper");
1013export const mainScriptStr: string = getAppScriptStr("main");
1114export const settingsScriptStr: string = getAppScriptStr("settings");
12151313-export const appScriptStrs = [postHelperScriptStr, repostHelperScriptStr, mainScriptStr, settingsScriptStr];1616+export const appScriptStrs = [mainScriptStr, appScriptStr, altTextScriptStr, tributeScriptStr,
1717+ postHelperScriptStr, repostHelperScriptStr, settingsScriptStr];
+142-64
src/utils/bskyApi.ts
···55import has from 'just-has';
66import isEmpty from "just-is-empty";
77import truncate from "just-truncate";
88-import { BSKY_IMG_SIZE_LIMIT, MAX_ALT_TEXT, MAX_EMBEDS_PER_POST, MAX_POSTED_LENGTH } from '../limits';
88+import { BSKY_IMG_SIZE_LIMIT, MAX_ALT_TEXT, MAX_EMBEDS_PER_POST } from '../limits';
99import {
1010 Bindings, BskyEmbedWrapper, BskyRecordWrapper, EmbedData, EmbedDataType,
1111- LooseObj, AccountStatus, Post, PostLabel,
1212- PostResponseObject, Repost, ScheduledContext
1111+ LooseObj, Post, PostLabel, AccountStatus,
1212+ PostRecordResponse, PostStatus, Repost, ScheduledContext
1313} from '../types.d';
1414import { atpRecordURI } from '../validation/regexCases';
1515+import { bulkUpdatePostedData, getChildPostsOfThread, isPostAlreadyPosted, setPostNowOffForPost } from './db/data';
1516import { getBskyUserPassForId, getUsernameForUserId } from './db/userinfo';
1617import { createViolationForUser } from './db/violations';
1718import { deleteEmbedsFromR2 } from './r2Query';
1818-import { isPostAlreadyPosted, setPostNowOffForPost, updatePostData } from './db/data';
19192020export const doesHandleExist = async (user: string) => {
2121 try {
···124124 return agent;
125125}
126126127127-export const makePost = async (c: Context|ScheduledContext, content: Post|null, isQueued: boolean=false, usingAgent: AtpAgent|null=null) => {
128128- if (content === null)
127127+export const makePost = async (c: Context|ScheduledContext, content: Post|null, usingAgent: AtpAgent|null=null) => {
128128+ if (content === null) {
129129+ console.warn("Dropping invocation of makePost, content was null");
129130 return false;
130130-131131+ }
132132+131133 const env = c.env;
132134 // make a check to see if the post has already been posted onto bsky
133133- if (await isPostAlreadyPosted(env, content.postid)) {
134134- console.log(`Dropped handling make post for post ${content.postid}, already posted.`)
135135+ // skip over this check if we are a threaded post, as we could have had a child post that didn't make it.
136136+ if (!content.isThreadRoot && await isPostAlreadyPosted(env, content.postid)) {
137137+ console.log(`Dropped handling make post for post ${content.postid}, already posted.`);
135138 return true;
136139 }
137140···140143 console.warn(`could not make agent for post ${content.postid}`);
141144 return false;
142145 }
143143- const newPost: PostResponseObject|null = await makePostRaw(env, content, agent);
144144- if (newPost !== null) {
145145- // update post data in the d1
146146- const postDataUpdate: Promise<boolean> = updatePostData(env, content.postid, { posted: true, uri: newPost.uri, cid: newPost.cid,
147147- content: truncate(content.text, MAX_POSTED_LENGTH), embedContent: [] });
148148- if (isQueued)
149149- await postDataUpdate;
150150- else
151151- c.executionCtx.waitUntil(postDataUpdate);
146146+147147+ const newPostRecords: PostStatus|null = await makePostRaw(env, content, agent);
148148+ if (newPostRecords !== null) {
149149+ await bulkUpdatePostedData(env, newPostRecords.records, newPostRecords.expected == newPostRecords.got);
152150153151 // Delete any embeds if they exist.
154154- await deleteEmbedsFromR2(c, content.embeds, isQueued);
155155- return true;
152152+ for (const record of newPostRecords.records) {
153153+ if (record.postID === null)
154154+ continue;
155155+156156+ c.executionCtx.waitUntil(deleteEmbedsFromR2(c, record.embeds, true));
157157+ }
158158+159159+ // if we had a total success, return true.
160160+ return newPostRecords.expected == newPostRecords.got;
161161+ } else if (!content.postNow) {
162162+ console.warn(`Post records for ${content.postid} was null, the schedule post failed`);
156163 }
157157-164164+158165 // Turn off the post now flag if we failed.
159166 if (content.postNow) {
160160- if (isQueued)
161161- await setPostNowOffForPost(env, content.postid);
162162- else
163163- c.executionCtx.waitUntil(setPostNowOffForPost(env, content.postid));
167167+ c.executionCtx.waitUntil(setPostNowOffForPost(env, content.postid));
164168 }
165169 return false;
166170}
···181185 // the only thing that actually matters is the object below.
182186 //console.warn(`failed to unrepost post ${content.uri} with err ${err}`);
183187 }
184184-188188+185189 try {
186190 await agent.repost(content.uri, content.cid);
187191 } catch(err) {
···192196 return bWasSuccess;
193197};
194198195195-export const makePostRaw = async (env: Bindings, content: Post, agent: AtpAgent) => {
199199+export const makePostRaw = async (env: Bindings, content: Post, agent: AtpAgent): Promise<PostStatus|null> => {
196200 const username = await getUsernameForUserId(env, content.user);
197201 // incredibly unlikely but we'll handle it
198202 if (username === null) {
···200204 return null;
201205 }
202206203203- const rt = new RichText({
204204- text: content.text,
205205- });
207207+ // Easy lookup map for reply mapping for this post chain
208208+ const postMap = new Map();
206209207207- await rt.detectFacets(agent);
210210+ // Lambda that handles making a post record and submitting it to bsky
211211+ const postSegment = async (postData: Post) => {
212212+ let currentEmbedIndex = 0;
208213209209- // This used to be so that we could handle posts in threads, but it turns out that threading is more annoying
210210- // As if anything fails, you have to roll back pretty hard.
211211- // So threading is dropped. But here's the code if we wanted to bring it back in the future.
212212- let currentEmbedIndex = 0;
213213- const posts: PostResponseObject[] = [];
214214+ const rt = new RichText({
215215+ text: postData.text,
216216+ });
214217215215- const postSegment = async (data: string) => {
218218+ await rt.detectFacets(agent);
216219 let postRecord: AppBskyFeedPost.Record = {
217220 $type: 'app.bsky.feed.post',
218218- text: data,
221221+ text: rt.text,
219222 facets: rt.facets,
220223 createdAt: new Date().toISOString(),
221224 };
222222- if (content.label !== undefined && content.label !== PostLabel.None) {
225225+ if (postData.label !== undefined && postData.label !== PostLabel.None) {
223226 let contentValues = [];
224224- switch (content.label) {
227227+ switch (postData.label) {
225228 case PostLabel.Adult:
226229 contentValues.push({"val": "porn"});
227230 break;
···246249 }
247250248251 // Upload any embeds to this post
249249- if (content.embeds?.length) {
252252+ if (postData.embeds?.length) {
250253 let mediaEmbeds: BskyEmbedWrapper = { type: EmbedDataType.None };
251254 let imagesArray = [];
252255 let bskyRecordInfo: BskyRecordWrapper = {};
253256 let embedsProcessed: number = 0;
254257 const isRecordViolation = (attemptToWrite: EmbedDataType) => {
255255- return mediaEmbeds.type != EmbedDataType.None && mediaEmbeds.type != attemptToWrite
258258+ return mediaEmbeds.type != EmbedDataType.None && mediaEmbeds.type != attemptToWrite
256259 && mediaEmbeds.type != EmbedDataType.Record && attemptToWrite != EmbedDataType.Record;
257260 }
258261 // go until we run out of embeds or have hit the amount of embeds per post (+1 because there could be a record with media)
259259- for (; embedsProcessed < MAX_EMBEDS_PER_POST + 1 && currentEmbedIndex < content.embeds.length; ++currentEmbedIndex, ++embedsProcessed) {
260260- const currentEmbed: EmbedData = content.embeds[currentEmbedIndex];
262262+ for (; embedsProcessed < MAX_EMBEDS_PER_POST + 1 && currentEmbedIndex < postData.embeds.length; ++currentEmbedIndex, ++embedsProcessed) {
263263+ const currentEmbed: EmbedData = postData.embeds[currentEmbedIndex];
261264 const currentEmbedType: EmbedDataType = currentEmbed.type;
262265263266 // If we never saw any record info, and the current type is not record itself, then we're on an overflow and need to back out.
···267270268271 // If we have encountered a record violation (illegal mixed media types), then we should stop processing further.
269272 if (isRecordViolation(currentEmbedType)) {
270270- console.error(`${content.postid} had a mixed media types of ${mediaEmbeds.type} trying to write ${currentEmbedType}`);
273273+ console.error(`${postData.postid} had a mixed media types of ${mediaEmbeds.type} trying to write ${currentEmbedType}`);
271274 break;
272275 }
273276···287290 let imageBlob = await thumbnail.blob();
288291 let thumbEncode = thumbnail.headers.get("content-type") || "image/png";
289292 if (imageBlob.size > BSKY_IMG_SIZE_LIMIT) {
290290- // Resize the thumbnail because while the blob service will accept
293293+ // Resize the thumbnail because while the blob service will accept
291294 // embed thumbnails of any size
292292- // it will fail when you try to make the post record, saying the
295295+ // it will fail when you try to make the post record, saying the
293296 // post record is invalid.
294297 const imgTransform = (await env.IMAGES.input(imageBlob.stream())
295298 .transform({width: 1280, height: 720, fit: "scale-down"})
···314317 continue;
315318 } else if (currentEmbedType == EmbedDataType.Record) {
316319 let changedRecord = false;
317317- // Write the record type if we don't have one set already
320320+ // Write the record type if we don't have one set already
318321 // (others can override this and the post will become a record with media instead)
319322 if (mediaEmbeds.type == EmbedDataType.None) {
320323 mediaEmbeds.type = EmbedDataType.Record;
···322325 }
323326324327 if (!isEmpty(bskyRecordInfo)) {
325325- console.warn(`${content.postid} attempted to write two record info objects`);
328328+ console.warn(`${postData.postid} attempted to write two record info objects`);
326329 continue;
327330 }
328331···374377 const uriResolve: string[] = [ uri ];
375378 const resolvePost = await getAgentPostRecords(agent, uriResolve);
376379 if (resolvePost === null) {
377377- console.error(`Unable to resolve record information for ${content.postid} with ${uri}`);
380380+ console.error(`Unable to resolve record information for ${postData.postid} with ${uri}`);
378381 // Change the record back.
379382 if (changedRecord)
380383 mediaEmbeds.type = EmbedDataType.None;
381384 continue;
382382- }
385385+ }
383386 if (resolvePost.length !== 0)
384387 cid = resolvePost[0].cid;
385388 }
···439442 } catch (err) {
440443 if (err instanceof XRPCError) {
441444 if (err.status === ResponseType.InternalServerError) {
442442- console.warn(`Encountered internal server error on ${currentEmbed.content} for post ${content.postid}`);
445445+ console.warn(`Encountered internal server error on ${currentEmbed.content} for post ${postData.postid}`);
443446 return false;
444447 }
445448 }
446449 // Give violation mediaTooBig if the file is too large.
447447- await createViolationForUser(env, content.user, AccountStatus.MediaTooBig);
448448- console.warn(`Unable to upload ${currentEmbed.content} for post ${content.postid} with err ${err}`);
450450+ await createViolationForUser(env, postData.user, AccountStatus.MediaTooBig);
451451+ console.warn(`Unable to upload ${currentEmbed.content} for post ${postData.postid} with err ${err}`);
449452 return false;
450453 }
451454···453456 if (!uploadFile.success) {
454457 console.warn(`failed to upload ${currentEmbed.content} to blob service`);
455458 return false;
456456- }
459459+ }
457460458461 // Handle images
459462 if (currentEmbedType == EmbedDataType.Image) {
···464467 };
465468 // Attempt to get the width and height of the image file.
466469 const sizeResult = await imageDimensionsFromStream(await fileBlob.stream());
467467- // If we were able to parse the width and height of the image,
470470+ // If we were able to parse the width and height of the image,
468471 // then append the "aspect ratio" into the image record.
469472 if (sizeResult) {
470473 bskyMetadata.aspectRatio = {
···540543 }
541544 }
542545546546+ // set up the thread chain
547547+ if (postData.isChildPost) {
548548+ const rootPostRecord: PostRecordResponse = postMap.get(postData.rootPost!);
549549+ const parentPostRecord: PostRecordResponse = postMap.get(postData.parentPost!);
550550+ if (!isEmpty(rootPostRecord) && !isEmpty(parentPostRecord)) {
551551+ (postRecord as any).reply = {
552552+ "root": {
553553+ "uri": rootPostRecord.uri,
554554+ "cid": rootPostRecord.cid
555555+ },
556556+ "parent": {
557557+ "uri": parentPostRecord.uri,
558558+ "cid": parentPostRecord.cid
559559+ }
560560+ }
561561+ }
562562+ }
563563+543564 try {
544565 const response = await agent.post(postRecord);
545545- posts.push(response);
566566+ postMap.set(postData.postid,
567567+ { ...response,
568568+ embeds: postData.embeds,
569569+ postID: postData.postid
570570+ } as PostRecordResponse);
571571+ console.log(`Posted to Bluesky: ${response.uri}`);
546572 return true;
547573 } catch(err) {
548574 // This will try again in the future, next roundabout.
549549- console.error(`encountered error while trying to push post ${content.postid} up to bsky ${err}`);
575575+ console.error(`encountered error while trying to push post ${postData.postid} up to bsky ${err}`);
550576 }
551577 return false;
552578 };
553579580580+ let successThisRound = 0;
554581 // Attempt to make the post
555555- if (await postSegment(rt.text) === false) {
556556- return null;
582582+ if (!content.posted) {
583583+ if (await postSegment(content) === false)
584584+ return null;
585585+ else
586586+ successThisRound = 1;
587587+ } else if (content.isThreadRoot) {
588588+ // Thread posts with children that fail to be posted will be marked with
589589+ // posted: false in the database, but the cid will be populated.
590590+ //
591591+ // However, our helper code will translate the post object and return
592592+ // that it's actually posted: true
593593+ //
594594+ // Do not recreate the thread root in this scenario
595595+ // push the existing data into the post map
596596+ // so it can be referred to by other child posts.
597597+ //
598598+ // Only do this for thread roots, no one else.
599599+ postMap.set(content.postid,
600600+ { uri: content.uri,
601601+ cid: content.cid,
602602+ postID: content.postid
603603+ } as PostRecordResponse);
604604+ }
605605+606606+ // Assume that we succeeded here (failure returns null)
607607+ let successes = 1;
608608+ let expected = 1;
609609+610610+ // If this is a post thread root
611611+ if (content.isThreadRoot) {
612612+ const childPosts = await getChildPostsOfThread(env, content.postid) || [];
613613+ expected += childPosts.length;
614614+ // get the thread children.
615615+ for (const child of childPosts) {
616616+ // If this post is already posted, we might be trying to restore from a failed state
617617+ if (child.posted) {
618618+ postMap.set(child.postid, {postID: null, uri: child.uri!, cid: child.cid!});
619619+ successes += 1;
620620+ continue;
621621+ }
622622+ // This is the first child post we haven't handled yet, oof.
623623+ const childSuccess = await postSegment(child);
624624+ if (childSuccess === false) {
625625+ console.error(`We encountered errors attempting to post child ${child.postid}, returning what did get posted`);
626626+ break;
627627+ }
628628+ successes += 1;
629629+ successThisRound += 1;
630630+ }
557631 }
558632559559- // Make a note that we posted this to BSky
560560- console.log(`Posted to Bluesky: ${posts.map(p => p.uri)}`);
633633+ // Return a nice array for the folks at home
634634+ const returnObj: PostStatus = {
635635+ records: Array.from(postMap.values()).filter((post) => { return post.postID !== null;}),
636636+ expected: expected,
637637+ got: successes
638638+ }
639639+ console.log(`posted ${successes}/${expected}, did ${successThisRound} work units`);
561640562562- // store the first uri/cid
563563- return posts[0];
641641+ return returnObj;
564642}
565643566644export const getPostRecords = async (records:string[]) => {
+1-1
src/utils/bskyMsg.ts
···5050 } catch (delerr) {
5151 console.error(`failed to delete reset message for self, got error ${delerr}`);
5252 }
5353- // Message has been sent.
5353+ // Message has been sent.
5454 return true;
5555 } else {
5656 console.error(`Unable to send the message to ${user}, could not sendMessage call`);
···3535 // Post truncation
3636 if (postTruncation.length > 0) {
3737 console.log(`Attempting to clean up post truncation for ${postTruncation.length} posts`);
3838- // it would be nicer to do bulking of this, but the method to do so in drizzle leaves me uneasy (and totally not about to sql inject myself)
3939- // so we do each query uniquely instead.
4040- postTruncation.forEach(async item => {
3838+ for (const item of postTruncation) {
4139 console.log(`Updating post ${item.id}`);
4242- await db.update(posts).set({ content: truncate(item.content, MAX_POSTED_LENGTH) }).where(eq(posts.uuid, item.id));
4343- });
4040+ await db.update(posts).set({ content: sql`substr(posts.content, 0, ${MAX_POSTED_LENGTH})`}).where(eq(posts.uuid, item.id));
4141+ }
4442 }
45434644 // push timestamps
···5654 console.error(`Adding file listings got error ${err}`);
5755 }
58565959- let batchedQueries:BatchItem<"sqlite">[] = [];
5757+ let batchedQueries:BatchItem<"sqlite">[] = [];
6058 // Flag if the media file has embed data
6159 const allUsers = await db.select({id: users.id}).from(users).all();
6260 for (const user of allUsers) {
···6866 const allPosts = await db.select({id: posts.uuid}).from(posts);
6967 for (const post of allPosts) {
7068 const count = db.$count(reposts, eq(reposts.uuid, post.id));
7171- batchedQueries.push(db.insert(repostCounts).values({uuid: post.id,
6969+ batchedQueries.push(db.insert(repostCounts).values({uuid: post.id,
7270 count: count}).onConflictDoNothing());
7371 }
7472 await db.batch(batchedQueries as BatchQuery);
+3-3
src/utils/db/violations.ts
···5858}
59596060export const createViolationForUser = async(env: Bindings, userId: string, violationType: AccountStatus): Promise<boolean> => {
6161- const NoHandleState: AccountStatus[] = [AccountStatus.Ok, AccountStatus.PlatformOutage,
6161+ const NoHandleState: AccountStatus[] = [AccountStatus.Ok, AccountStatus.PlatformOutage,
6262 AccountStatus.None, AccountStatus.UnhandledError];
6363 // Don't do anything in these cases
6464 if (violationType in NoHandleState) {
···8383};
84848585export const getViolationDeleteQueryForUser = (db: DrizzleD1Database, userId: string) => {
8686- return db.delete(violations).where(and(eq(violations.userId, userId),
8686+ return db.delete(violations).where(and(eq(violations.userId, userId),
8787 and(ne(violations.tosViolation, true), ne(violations.accountGone, true))
8888 ));
8989};
···103103 const valuesUpdate:LooseObj = createObjForValuesChange(violationType, false);
104104 await db.update(violations).set({...valuesUpdate}).where(eq(violations.userId, userId));
105105 // Delete the record if the user has no other violations
106106- await db.delete(violations).where(and(eq(violations.userId, userId),
106106+ await db.delete(violations).where(and(eq(violations.userId, userId),
107107 and(
108108 and(
109109 and(ne(violations.accountSuspended, true), ne(violations.accountGone, true),
+164-53
src/utils/dbQuery.ts
···11import { addHours, isAfter, isEqual } from "date-fns";
22-import { and, desc, eq, getTableColumns } from "drizzle-orm";
22+import { and, asc, desc, eq, getTableColumns, gt, gte, sql } from "drizzle-orm";
33import { BatchItem } from "drizzle-orm/batch";
44import { drizzle, DrizzleD1Database } from "drizzle-orm/d1";
55import { Context } from "hono";
···88import { v4 as uuidv4, validate as uuidValid } from 'uuid';
99import { mediaFiles, posts, repostCounts, reposts } from "../db/app.schema";
1010import { accounts, users } from "../db/auth.schema";
1111-import { MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST } from "../limits";
1111+import { MAX_POSTS_PER_THREAD, MAX_REPOST_POSTS, MAX_REPOST_RULES_PER_POST } from "../limits";
1212import {
1313+ AccountStatus,
1314 BatchQuery,
1415 CreateObjectResponse, CreatePostQueryResponse,
1616+ DeleteResponse,
1517 EmbedDataType,
1616- AccountStatus,
1718 Post, PostLabel,
1819 RepostInfo
1920} from "../types.d";
2021import { PostSchema } from "../validation/postSchema";
2122import { RepostSchema } from "../validation/repostSchema";
2222-import { doesPostExist, updatePostForGivenUser } from "./db/data";
2323+import { getChildPostsOfThread, getPostThreadCount, updatePostForGivenUser } from "./db/data";
2324import { getViolationsForUser, removeViolation, removeViolations, userHasViolations } from "./db/violations";
2425import { createPostObject, createRepostInfo, floorGivenTime } from "./helpers";
2526import { deleteEmbedsFromR2 } from "./r2Query";
···2930 const userId = c.get("userId");
3031 if (userId) {
3132 const db: DrizzleD1Database = drizzle(c.env.DB);
3232- const results = await db.select({...getTableColumns(posts), repostCount: repostCounts.count})
3333+ const results = await db.select({
3434+ ...getTableColumns(posts),
3535+ repostCount: repostCounts.count
3636+ })
3337 .from(posts).where(eq(posts.userId, userId))
3438 .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid))
3535- .orderBy(desc(posts.scheduledDate), desc(posts.createdAt)).all();
3636-3939+ .orderBy(desc(posts.scheduledDate), asc(posts.threadOrder), desc(posts.createdAt)).all();
4040+3741 if (isEmpty(results))
3842 return null;
39434044 return results.map((itm) => createPostObject(itm));
4145 }
4246 } catch(err) {
4343- console.error(`Failed to get posts for user, session could not be fetched ${err}`);
4747+ console.error(`Failed to get posts for user, session could not be fetched ${err}`);
4448 }
4549 return null;
4650};
···8791 return false;
8892};
89939090-export const deletePost = async (c: Context, id: string): Promise<boolean> => {
9494+export const deletePost = async (c: Context, id: string): Promise<DeleteResponse> => {
9195 const userId = c.get("userId");
9696+ const returnObj: DeleteResponse = {success: false};
9297 if (!userId) {
9393- return false;
9898+ return returnObj;
9499 }
9510096101 const db: DrizzleD1Database = drizzle(c.env.DB);
9797- const postQuery = await db.select().from(posts).where(and(eq(posts.uuid, id), eq(posts.userId, userId))).all();
9898- if (postQuery.length !== 0) {
102102+ const postObj = await getPostById(c, id);
103103+ if (postObj !== null) {
104104+ let queriesToExecute:BatchItem<"sqlite">[] = [];
99105 // If the post has not been posted, that means we still have files for it, so
100106 // delete the files from R2
101101- if (!postQuery[0].posted) {
102102- await deleteEmbedsFromR2(c, createPostObject(postQuery[0]).embeds);
107107+ if (!postObj.posted) {
108108+ await deleteEmbedsFromR2(c, postObj.embeds);
103109 if (await userHasViolations(db, userId)) {
104110 // Remove the media too big violation if it's been given
105111 await removeViolation(c.env, userId, AccountStatus.MediaTooBig);
106112 }
107113 }
108114109109- c.executionCtx.waitUntil(db.delete(posts).where(eq(posts.uuid, id)));
110110- return true;
115115+ // If the parent post is not null, then attempt to find and update the post chain
116116+ const parentPost = postObj.parentPost;
117117+ if (parentPost !== undefined) {
118118+ // set anyone who had this as their parent to this post chain
119119+ queriesToExecute.push(db.update(posts).set({parentPost: parentPost, threadOrder: postObj.threadOrder})
120120+ .where(and(eq(posts.parentPost, postObj.postid), eq(posts.rootPost, postObj.rootPost!))));
121121+122122+ // Update the post order past here
123123+ queriesToExecute.push(db.update(posts).set({threadOrder: sql`threadOrder - 1`})
124124+ .where(
125125+ and(eq(posts.rootPost, postObj.rootPost!), gt(posts.threadOrder, postObj.threadOrder)
126126+ )));
127127+ }
128128+129129+ // We'll need to delete all of the child embeds then, a costly, annoying experience.
130130+ if (postObj.isThreadRoot) {
131131+ const childPosts = await getChildPostsOfThread(c.env, postObj.postid);
132132+ if (childPosts !== null) {
133133+ for (const childPost of childPosts) {
134134+ c.executionCtx.waitUntil(deleteEmbedsFromR2(c, childPost.embeds));
135135+ queriesToExecute.push(db.delete(posts).where(eq(posts.uuid, childPost.postid)));
136136+ }
137137+ } else {
138138+ console.warn(`could not get child posts of thread ${postObj.postid} during delete`);
139139+ }
140140+ } else if (postObj.isChildPost) {
141141+ // this is not a thread root, so we should figure out how many children are left.
142142+ const childPostCount = (await getPostThreadCount(c.env, postObj.user, postObj.rootPost!)) - 1;
143143+ if (childPostCount <= 0) {
144144+ queriesToExecute.push(db.update(posts).set({threadOrder: -1}).where(eq(posts.uuid, postObj.rootPost!)));
145145+ }
146146+ }
147147+148148+ // delete post
149149+ queriesToExecute.push(db.delete(posts).where(eq(posts.uuid, id)));
150150+ await c.executionCtx.waitUntil(db.batch(queriesToExecute as BatchQuery));
151151+ returnObj.success = true;
152152+ returnObj.needsRefresh = postObj.isThreadRoot;
111153 }
112112- return false;
154154+ return returnObj;
113155};
114156115157export const createPost = async (c: Context, body: any): Promise<CreatePostQueryResponse> => {
···128170 const scheduleDate = floorGivenTime((makePostNow) ? new Date() : new Date(scheduledDate));
129171130172 // Ensure scheduled date is in the future
131131- if (!isAfter(scheduleDate, new Date()) && !makePostNow) {
173173+ //
174174+ // Do not do this check if you are doing a threaded post
175175+ // or you have marked that you are posting right now.
176176+ if (!isAfter(scheduleDate, new Date()) &&
177177+ (!makePostNow && (isEmpty(rootPost) && isEmpty(parentPost)))) {
132178 return { ok: false, msg: "Scheduled date must be in the future" };
133179 }
134180···146192 let rootPostID:string|undefined = undefined;
147193 let parentPostID:string|undefined = undefined;
148194 let rootPostData: Post|null = null;
195195+ let parentPostOrder: number = 0;
149196 if (uuidValid(rootPost)) {
150197 // returns null if the post doesn't appear on this account
151198 rootPostData = await getPostById(c, rootPost!);
···153200 if (rootPostData.posted) {
154201 return { ok: false, msg: "You cannot make threads off already posted posts"};
155202 }
156156- rootPostID = rootPostData.rootPost!;
203203+ if (rootPostData.isChildPost) {
204204+ return { ok: false, msg: "Subthreads of threads are not allowed." };
205205+ }
206206+ if (rootPostData.isRepost) {
207207+ return {ok: false, msg: "Threads cannot be made of repost actions"};
208208+ }
209209+ rootPostID = rootPostData.rootPost || rootPostData.postid;
157210 // If this isn't a direct reply, check directly underneath it
158211 if (rootPost !== parentPost) {
159212 if (uuidValid(parentPost)) {
160160- if (await doesPostExist(c.env, userId, parentPost!)) {
213213+ const parentPostData = await getPostById(c, parentPost!);
214214+ if (parentPostData !== null) {
161215 parentPostID = parentPost!;
216216+ parentPostOrder = parentPostData.threadOrder + 1;
162217 } else {
163218 return { ok: false, msg: "The given parent post cannot be found on your account"};
164219 }
220220+ } else {
221221+ return { ok: false, msg: "The given parent post is invalid"};
165222 }
166223 } else {
167167- parentPostID = rootPost!;
224224+ parentPostID = rootPostData.postid;
225225+ parentPostOrder = 1; // Root will always be 0, so if this is root, go 1 up.
168226 }
169227 } else {
170228 return { ok: false, msg: "The given root post cannot be found on your account"};
171229 }
172230 }
173173- const isThreadedPost:boolean = (rootPostID !== undefined && parentPostID !== undefined);
231231+232232+ const isThreadedPost: boolean = (rootPostID !== undefined && parentPostID !== undefined);
233233+ if (isThreadedPost) {
234234+ const threadCount: number = await getPostThreadCount(c.env, userId, rootPostID!);
235235+ if (threadCount >= MAX_POSTS_PER_THREAD) {
236236+ return { ok: false, msg: `this thread has hit the limit of ${MAX_POSTS_PER_THREAD} posts per thread`};
237237+ }
238238+ }
174239175240 // Create repost metadata
176241 const scheduleGUID = (!isThreadedPost) ? uuidv4() : undefined;
177177- const repostInfo = (!isThreadedPost) ? createRepostInfo(scheduleGUID!, scheduleDate, false, repostData) : undefined;
178178-242242+ const repostInfo = (!isThreadedPost) ?
243243+ createRepostInfo(scheduleGUID!, scheduleDate, false, repostData) : undefined;
244244+179245 // Create the posts
180246 const postUUID = uuidv4();
181181- let dbOperations: BatchItem<"sqlite">[] = [
182182- db.insert(posts).values({
247247+ let dbOperations: BatchItem<"sqlite">[] = [];
248248+249249+ // if we're threaded, insert our post before the given parent
250250+ if (isThreadedPost) {
251251+ // Update the parent to our new post
252252+ dbOperations.push(db.update(posts).set({parentPost: postUUID })
253253+ .where(and(eq(posts.parentPost, parentPostID!), eq(posts.rootPost, rootPostID!))));
254254+255255+ // update all posts past this one to also update their order (we will take their id)
256256+ dbOperations.push(db.update(posts).set({threadOrder: sql`threadOrder + 1`})
257257+ .where(
258258+ and(eq(posts.rootPost, rootPostID!), gte(posts.threadOrder, parentPostOrder)
259259+ )));
260260+261261+ // Update the root post so that it has the correct flags set on it as well.
262262+ if (rootPostData!.isThreadRoot == false) {
263263+ dbOperations.push(db.update(posts).set({threadOrder: 0, rootPost: rootPostData!.postid})
264264+ .where(eq(posts.uuid, rootPostData!.postid)));
265265+ }
266266+ } else {
267267+ rootPostID = postUUID;
268268+ }
269269+270270+ // Add the post to the DB
271271+ dbOperations.push(db.insert(posts).values({
183272 content,
184273 uuid: postUUID,
185274 postNow: makePostNow,
186186- scheduledDate: (!isThreadedPost) ? scheduleDate : new Date(rootPostData?.scheduledDate!),
187187- /*isThread: isThreadedPost,
275275+ scheduledDate: (!isThreadedPost) ? scheduleDate : new Date(rootPostData!.scheduledDate!),
188276 rootPost: rootPostID,
189189- parentPost: parentPostID,*/
277277+ parentPost: parentPostID,
190278 repostInfo: (!isThreadedPost) ? [repostInfo!] : [],
279279+ threadOrder: (!isThreadedPost) ? undefined : parentPostOrder,
191280 embedContent: embeds,
192281 contentLabel: label || PostLabel.None,
193282 userId: userId
194194- })
195195- ];
283283+ }));
196284197285 if (!isEmpty(embeds)) {
198286 // Loop through all data within an embed blob so we can mark it as posted
···238326 const { url, uri, cid, scheduledDate, repostData } = validation.data;
239327 const scheduleDate = floorGivenTime(new Date(scheduledDate));
240328 const timeNow = new Date();
241241-329329+242330 // Ensure scheduled date is in the future
243331 if (!isAfter(scheduleDate, timeNow)) {
244332 return { ok: false, msg: "Scheduled date must be in the future" };
···260348261349 // Check to see if the post already exists
262350 // (check also against the userId here as well to avoid cross account data collisions)
263263- const existingPost = await db.select({id: posts.uuid, date: posts.scheduledDate, curRepostInfo: posts.repostInfo})
264264- .from(posts).where(and(
265265- eq(posts.userId, userId), eq(posts.cid, cid)))
266266- .limit(1).all();
267267-268268- const hasExistingPost:boolean = existingPost.length >= 1;
269269- if (hasExistingPost) {
270270- postUUID = existingPost[0].id;
271271- const existingPostDate = existingPost[0].date;
351351+ const existingPost = await getPostByCID(c, cid);
352352+ if (existingPost !== null) {
353353+ postUUID = existingPost.postid;
354354+ const existingPostDate = existingPost.scheduledDate!;
272355 // Ensure the date asked for is after what the post's schedule date is
273356 if (!isAfter(scheduleDate, existingPostDate) && !isEqual(scheduledDate, existingPostDate)) {
274357 return { ok: false, msg: "Scheduled date must be after the initial post's date" };
275358 }
359359+ // Make sure this isn't a thread post.
360360+ // We could probably work around this but I don't think it's worth the effort.
361361+ if (existingPost.isChildPost) {
362362+ return {ok: false, msg: "Repost posts cannot be created from child thread posts"};
363363+ }
364364+276365 // Add repost info object to existing array
277277- let newRepostInfo:RepostInfo[] = isEmpty(existingPost[0].curRepostInfo) ? [] : existingPost[0].curRepostInfo!;
366366+ let newRepostInfo:RepostInfo[] = isEmpty(existingPost.repostInfo) ? [] : existingPost.repostInfo!;
278367 if (newRepostInfo.length >= MAX_REPOST_RULES_PER_POST) {
279368 return {ok: false, msg: `Num of reposts rules for this post has exceeded the limit of ${MAX_REPOST_RULES_PER_POST} rules`};
280369 }
···287376 // Limit of post reposts on the user's account.
288377 const accountCurrentReposts = await db.$count(posts, and(eq(posts.userId, userId), eq(posts.isRepost, true)));
289378 if (MAX_REPOST_POSTS > 0 && accountCurrentReposts >= MAX_REPOST_POSTS) {
290290- return {ok: false, msg:
379379+ return {ok: false, msg:
291380 `You've cannot create any more repost posts at this time. Using: (${accountCurrentReposts}/${MAX_REPOST_POSTS}) repost posts`};
292381 }
293382···313402 scheduleGuid: scheduleGUID,
314403 scheduledDate: scheduleDate
315404 }).onConflictDoNothing());
316316-405405+317406 // Push other repost times if we have them
318407 if (repostData) {
319408 for (var i = 1; i <= repostData.times; ++i) {
···326415 totalRepostCount += repostData.times;
327416 }
328417 // Update repost counts
329329- if (hasExistingPost) {
418418+ if (existingPost !== null) {
330419 const newCount = db.$count(reposts, eq(reposts.uuid, postUUID));
331420 dbOperations.push(db.update(repostCounts)
332421 .set({count: newCount})
···337426338427 const batchResponse = await db.batch(dbOperations as BatchQuery);
339428 const success = batchResponse.every((el) => el.success);
340340- return { ok: success, msg: success ? "success" : "fail" };
429429+ return { ok: success, msg: success ? "success" : "fail", postId: postUUID };
341430};
342431343432export const updatePostForUser = async (c: Context, id: string, newData: Object) => {
344433 const userId = c.get("userId");
345345- return await updatePostForGivenUser(c, userId, id, newData);
434434+ return await updatePostForGivenUser(c.env, userId, id, newData);
346435};
347436348437export const getPostById = async(c: Context, id: string): Promise<Post|null> => {
···352441353442 const env = c.env;
354443 const db: DrizzleD1Database = drizzle(env.DB);
355355- const result = await db.select().from(posts).where(and(eq(posts.uuid, id), eq(posts.userId, userId))).limit(1).all();
444444+ const result = await db.select().from(posts)
445445+ .where(and(eq(posts.uuid, id), eq(posts.userId, userId)))
446446+ .limit(1).all();
447447+356448 if (!isEmpty(result))
357449 return createPostObject(result[0]);
358450 return null;
359451};
360452453453+export const getPostByCID = async(c: Context, cid: string): Promise<Post|null> => {
454454+ const userId = c.get("userId");
455455+ if (!userId)
456456+ return null;
457457+458458+ const env = c.env;
459459+ const db: DrizzleD1Database = drizzle(env.DB);
460460+ const result = await db.select().from(posts)
461461+ .where(and(eq(posts.userId, userId), eq(posts.cid, cid)))
462462+ .limit(1).all();
463463+464464+ if (!isEmpty(result))
465465+ return createPostObject(result[0]);
466466+ return null;
467467+}
468468+361469// used for post editing, acts very similar to getPostsForUser
362470export const getPostByIdWithReposts = async(c: Context, id: string): Promise<Post|null> => {
363471 const userId = c.get("userId");
···366474367475 const env = c.env;
368476 const db: DrizzleD1Database = drizzle(env.DB);
369369- const result = await db.select({...getTableColumns(posts), repostCount: repostCounts.count}).from(posts)
370370- .where(and(eq(posts.uuid, id), eq(posts.userId, userId)))
371371- .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid))
372372- .limit(1).all();
477477+ const result = await db.select({
478478+ ...getTableColumns(posts),
479479+ repostCount: repostCounts.count,
480480+ }).from(posts)
481481+ .where(and(eq(posts.uuid, id), eq(posts.userId, userId)))
482482+ .leftJoin(repostCounts, eq(posts.uuid, repostCounts.uuid))
483483+ .limit(1).all();
373484374485 if (!isEmpty(result))
375486 return createPostObject(result[0]);
376376- return null;
487487+ return null;
377488};
+25-11
src/utils/helpers.ts
···1111 postData.label = data.contentLabel;
1212 postData.text = data.content;
1313 postData.postNow = data.postNow;
1414- if (data.repostCount)
1414+ postData.threadOrder = data.threadOrder;
1515+1616+ if (has(data, "repostCount"))
1517 postData.repostCount = data.repostCount;
16181717- if (data.posted)
1818- postData.posted = data.posted;
1919 if (data.scheduledDate)
2020 postData.scheduledDate = data.scheduledDate;
2121-2222- if (data.isRepost)
2323- postData.isRepost = data.isRepost;
24212522 if (data.repostInfo)
2623 postData.repostInfo = data.repostInfo;
27242828- if (data.rootPost && data.parentPost) {
2525+ if (data.rootPost)
2626+ postData.rootPost = data.rootPost;
2727+2828+ if (data.parentPost) {
2929 postData.parentPost = data.parentPost;
3030- postData.rootPost = data.rootPost;
3131- postData.isThread = true;
3030+ postData.isChildPost = true;
3231 } else {
3333- postData.isThread = false;
3434- }
3232+ postData.isChildPost = false;
3333+ }
3434+3535+ if (data.threadOrder == 0)
3636+ postData.isThreadRoot = true;
3737+ else
3838+ postData.isThreadRoot = false;
35393640 // ATProto data
3741 if (data.uri)
3842 postData.uri = data.uri;
3943 if (data.cid)
4044 postData.cid = data.cid;
4545+4646+ if (has(data, "isRepost"))
4747+ postData.isRepost = data.isRepost;
4848+4949+ if (has(data, "posted"))
5050+ postData.posted = data.posted;
5151+5252+ // if a cid flag appears for the object and it's a thread root, then the post (if marked not posted) is posted.
5353+ if (postData.posted == false && !isEmpty(data.cid) && postData.isThreadRoot)
5454+ postData.posted = true;
41554256 return postData;
4357}
+1-1
src/utils/inviteKeys.ts
···4848 }
4949 // check the amount we have
5050 const amount: number = parseInt(value);
5151-5151+5252 // handle NaN
5353 if (isNaN(amount)) {
5454 console.warn(`${inviteKey} has the value of ${value} which triggers NaN.`);
+11-5
src/utils/queuePublisher.ts
···1616 return get(env, queueName, null);
1717};
18181919-export const isQueueEnabled = (env: Bindings) => env.QUEUE_SETTINGS.enabled;
2020-export const shouldPostNowQueue = (env: Bindings) => env.QUEUE_SETTINGS.postNowEnabled || false;
1919+const hasPostQueue = (env: Bindings) => !isEmpty(env.QUEUE_SETTINGS.post_queues) && env.IN_DEV == false;
2020+const hasRepostQueue = (env: Bindings) => !isEmpty(env.QUEUE_SETTINGS.repost_queues) && env.IN_DEV == false;
2121+export const isQueueEnabled = (env: Bindings) => env.QUEUE_SETTINGS.enabled && hasPostQueue(env);
2222+export const isRepostQueueEnabled = (env: Bindings) => env.QUEUE_SETTINGS.repostsEnabled && hasRepostQueue(env);
2323+export const shouldPostNowQueue = (env: Bindings) => env.QUEUE_SETTINGS.postNowEnabled && isQueueEnabled(env);
2424+export const shouldPostThreadQueue = (env: Bindings) => env.QUEUE_SETTINGS.threadEnabled && (hasPostQueue(env) || isQueueEnabled(env));
21252226export async function enqueuePost(env: Bindings, post: Post) {
2323- if (!isQueueEnabled(env))
2727+ if (post.isThreadRoot && !shouldPostThreadQueue(env))
2828+ return;
2929+ else if (!isQueueEnabled(env))
2430 return;
25312632 // Pick a random consumer to handle this post
···3036}
31373238export async function enqueueRepost(env: Bindings, post: Repost) {
3333- if (!isQueueEnabled(env))
3939+ if (!isRepostQueueEnabled(env))
3440 return;
3535-4141+3642 // Pick a random consumer to handle this repost
3743 const queueConsumer: Queue|null = getRandomQueue(env, "repost_queues");
3844 if (queueConsumer !== null)
+6-6
src/utils/r2Query.ts
···7070 });
7171 if (R2UploadRes) {
7272 await addFileListing(env, fileName, metaData.user);
7373- return {"success": true, "data": R2UploadRes.key,
7474- "originalName": metaData.name, "fileSize": metaData.size,
7373+ return {"success": true, "data": R2UploadRes.key,
7474+ "originalName": metaData.name, "fileSize": metaData.size,
7575 "qualityLevel": metaData.qualityLevel};
7676 } else {
7777 return {"success": false, "error": "unable to push to file storage"};
···140140 const returnType = response.headers.get("Content-Type") || "";
141141 const transformFileSize: number = Number(response.headers.get("Content-Length")) || 0;
142142 const resizeHadError = resizedHeader === null || resizedHeader.indexOf("err=") !== -1;
143143-143143+144144 if (!resizeHadError && BSKY_IMG_MIME_TYPES.includes(returnType)) {
145145 console.log(`Attempting quality level ${qualityLevel}% for ${originalName}, size: ${transformFileSize}`);
146146-147147- // If we make the file size less than the actual limit
146146+147147+ // If we make the file size less than the actual limit
148148 if (transformFileSize < BSKY_IMG_SIZE_LIMIT && transformFileSize !== 0) {
149149 console.log(`${originalName}: Quality level ${qualityLevel}% processed, fits correctly with size.`);
150150 failedToResize = false;
···196196 // Technically this will never hit because it is greater than our own internal limits
197197 if (file.size > BSKY_VIDEO_SIZE_LIMIT) {
198198 return {"success": false, "error": `max video size is ${BSKY_VIDEO_SIZE_LIMIT}MB`};
199199- }
199199+ }
200200201201 const fileMetaData: FileMetaData = {
202202 name: file.name,
+17-17
src/utils/scheduler.ts
···11+import AtpAgent from '@atproto/api';
12import isEmpty from 'just-is-empty';
23import { Bindings, Post, Repost, ScheduledContext } from '../types.d';
34import { makeAgentForUser, makePost, makeRepost } from './bskyApi';
45import { pruneBskyPosts } from './bskyPrune';
66+import { deleteAllRepostsBeforeCurrentTime, deletePosts, getAllPostsForCurrentTime, getAllRepostsForCurrentTime, purgePostedPosts } from './db/data';
57import { getAllAbandonedMedia } from './db/file';
66-import { enqueuePost, enqueueRepost, isQueueEnabled } from './queuePublisher';
88+import { enqueuePost, enqueueRepost, isQueueEnabled, isRepostQueueEnabled, shouldPostThreadQueue } from './queuePublisher';
79import { deleteFromR2 } from './r2Query';
88-import { getAllPostsForCurrentTime, getAllRepostsForCurrentTime, deleteAllRepostsBeforeCurrentTime, purgePostedPosts, deletePosts } from './db/data';
99-import AtpAgent from '@atproto/api';
10101111-export const handlePostTask = async(runtime: ScheduledContext, postData: Post, agent: AtpAgent|null, isQueued: boolean = false) => {
1212- const madePost = await makePost(runtime, postData, isQueued, agent);
1111+export const handlePostTask = async(runtime: ScheduledContext, postData: Post, agent: AtpAgent|null) => {
1212+ const madePost = await makePost(runtime, postData, agent);
1313 if (madePost) {
1414 console.log(`Made post ${postData.postid} successfully`);
1515 } else {
···3131 const scheduledPosts: Post[] = await getAllPostsForCurrentTime(env);
3232 const scheduledReposts: Repost[] = await getAllRepostsForCurrentTime(env);
3333 const queueEnabled: boolean = isQueueEnabled(env);
3434+ const repostQueueEnabled: boolean = isRepostQueueEnabled(env);
3535+ const threadQueueEnabled: boolean = shouldPostThreadQueue(env);
34363537 const runtimeWrapper: ScheduledContext = {
3638 executionCtx: ctx,
···4446 // TODO: bunching as a part of queues, literally just throw an agent at a queue with instructions and go.
4547 // this requires queueing to be working properly.
4648 const AgentList = new Map();
4747- const usesAgentMap = (env.SITE_SETTINGS.use_agent_map);
4848-4949+ const usesAgentMap: boolean = (env.SITE_SETTINGS.use_agent_map) || false;
5050+4951 // Push any posts
5052 if (!isEmpty(scheduledPosts)) {
5153 console.log(`handling ${scheduledPosts.length} posts...`);
5252- scheduledPosts.forEach(async (post) => {
5353- if (!queueEnabled) {
5454+ for (const post of scheduledPosts) {
5555+ if (queueEnabled || (post.isThreadRoot && threadQueueEnabled)) {
5656+ await enqueuePost(env, post);
5757+ } else {
5458 let agent = (usesAgentMap) ? AgentList.get(post.user) || null : null;
5559 if (agent === null) {
5660 agent = await makeAgentForUser(env, post.user);
···5862 AgentList.set(post.user, agent);
5963 }
6064 ctx.waitUntil(handlePostTask(runtimeWrapper, post, agent));
6161- } else {
6262- await enqueuePost(env, post);
6365 }
6464-6565- });
6666+ }
6667 } else {
6768 console.log("no posts scheduled for this time");
6869 }
···7071 // Push any reposts
7172 if (!isEmpty(scheduledReposts)) {
7273 console.log(`handling ${scheduledReposts.length} reposts`);
7373- scheduledReposts.forEach(async (repost) => {
7474- if (!queueEnabled) {
7474+ for (const repost of scheduledReposts) {
7575+ if (!repostQueueEnabled) {
7576 let agent = (usesAgentMap) ? AgentList.get(repost.userId) || null : null;
7677 if (agent === null) {
7778 agent = await makeAgentForUser(env, repost.userId);
···8283 } else {
8384 await enqueueRepost(env, repost);
8485 }
8585-8686- });
8686+ };
8787 ctx.waitUntil(deleteAllRepostsBeforeCurrentTime(env));
8888 } else {
8989 console.log("no reposts scheduled for this time");
+3-3
src/validation/embedSchema.ts
···29293030export const LinkEmbedSchema = z.object({
3131 /* content is the thumbnail */
3232- content: z.string().trim().prefault("").refine((value) => {
3232+ content: z.string().trim().prefault("").refine((value) => {
3333 if (isEmpty(value))
3434 return true;
3535 // So the idea here is to try to encode the string into an URL object, and if that fails
···4646 }),
4747 type: z.literal(EmbedDataType.WebLink),
4848 title: z.string().trim().default(""),
4949- /* NOTE: uri is the link to the website here,
4949+ /* NOTE: uri is the link to the website here,
5050 content is used as the thumbnail */
5151 uri: z.url({
5252- normalize: true,
5252+ normalize: true,
5353 protocol: /^https?$/,
5454 hostname: z.regexes.domain,
5555 error: "provided link is not an URL, please check URL and try again"