Something went wrong. Try again.
Schedule posts and reposts to Bluesky (and other PDSes) with Cloudflare workers. skyscheduler.work
scheduling social-media cloudflare bsky service cloudflare-workers bsky-tool bluesky
Something went wrong. Try again.
6.6 kB · 210 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210import isEmpty from "just-is-empty";import * as z from "zod/v4";import { DateValidCheck, EmbedDataType, PostLabel, TimeShape } from "../enums";import { MAX_EMBEDS_PER_POST, MAX_FUTURE_DATE_VALUE, MAX_IMAGES_PER_POST, MAX_LENGTH, MAX_RECORDS_PER_POST, MAX_VIDEOS_PER_POST, MAX_WEBLINKS_PER_POST, MIN_LENGTH} from "../limits";import { isDateValid } from "../utils/time";import { ImageEmbedSchema, LinkEmbedSchema, PostRecordSchema, VideoEmbedSchema} from "./embedSchema";import { FileContentSchema } from "./mediaSchema";import { RepostDataSchema } from "./repostDataSchema";import { AltTextSchema, ScheduledDateSchema } from "./sharedSchemas";
const TextContent = z.object({ content: z.string().trim() .min(MIN_LENGTH,`post is too short, min ${MIN_LENGTH} characters`) .max(MAX_LENGTH, `post is over ${MAX_LENGTH} characters`) .nonempty("post content cannot be empty") .nonoptional("post content must be provided"),});
// Schema for post creationexport const PostSchema = z.object({ ...TextContent.shape, embeds: z.discriminatedUnion("type", [ ImageEmbedSchema, LinkEmbedSchema, VideoEmbedSchema, PostRecordSchema ], "invalid media type").array().max(MAX_EMBEDS_PER_POST, "too many items were provided").optional(), contentLabel: z.enum(PostLabel, "content label must be set").optional(), makePostNow: z.boolean().default(false), rootPost: z.uuidv4("root post id is invalid").optional(), parentPost: z.uuidv4("parent post id is invalid").optional(), ...RepostDataSchema.shape, ...ScheduledDateSchema.shape,}).superRefine(({embeds, contentLabel, scheduledDate, makePostNow, repostData, rootPost, parentPost}, ctx) => { // Threaded post checks if (!isEmpty(rootPost) && !isEmpty(parentPost)) { // Make post now is not allowed on threaded posts if (makePostNow) { ctx.addIssue({ code: "custom", continue: false, message: "You cannot use the post now feature while making threads", path: ["makePostNow"] }); }
// make sure that repost data is explicitly missing for threads if (repostData !== undefined) { ctx.addIssue({ code: "custom", continue: false, message: "You cannot have reposts on child posts of threads", path: ["repostData"] }); } } else if (!makePostNow) { // If this is not a threaded post, and we are not doing a post now, // check that the post time is in the future const dateResult: DateValidCheck = isDateValid(new Date(scheduledDate), TimeShape.Post); if (dateResult === DateValidCheck.IsPastDate) { ctx.addIssue({ code: "custom", continue: false, message: "Scheduled posts must be set in the future", path: ["scheduledDate"] }); } else if (dateResult === DateValidCheck.TooFutureDate) { ctx.addIssue({ code: "custom", continue: false, message: `Scheduled posts can only be set a max of ${MAX_FUTURE_DATE_VALUE} years from the current date`, path: ["scheduledDate"] }); } } // Verify embed data if (embeds !== undefined && embeds.length > 0) { // Check if labels are set if (contentLabel === undefined) { // If we only have one record and it's a quote post, don't bother // checking for undefined labels or anything else in the refine if (embeds.length == 1 && embeds[0].type == EmbedDataType.Record) { return; }
ctx.addIssue({ code: "custom", continue: false, message: "Content labels are required for posting media", path: ["contentLabel"] }); }
// Count up the types of records we have let videoCount: number = 0, imageCount: number = 0, recordCount: number = 0, linkCount: number = 0, invalidCount: number = 0;
// Build up a usage map of all of the items in the embed stack embeds.forEach((itm) => { switch(itm.type) { case EmbedDataType.Video: ++videoCount; break; case EmbedDataType.Image: ++imageCount; break; case EmbedDataType.Record: ++recordCount; break; case EmbedDataType.WebLink: ++linkCount; break; default: ++invalidCount; break; } });
if (invalidCount > 0) { ctx.addIssue({ code: "custom", message: "There is invalid data in this post, please remove", path: ["embeds"] }); }
// Check for video violations if (videoCount > 0) { // Cannot have videos and images in the same post if (imageCount > 0) { ctx.addIssue({ code: "custom", message: "Videos and images cannot be uploaded to the same post", path: ["embeds"] }); } if (videoCount > MAX_VIDEOS_PER_POST) { ctx.addIssue({ code: "custom", message: "Only one video is allowed to be posted per post", path: ["embeds"] }); }
// Someone is being naughty if they get to this check if (linkCount > 0) { ctx.addIssue({ code: "custom", message: "Embed links cannot be included with videos", path: ["embeds"] }); } }
// Check for image violations if (imageCount > 0) { if (imageCount > MAX_IMAGES_PER_POST) { ctx.addIssue({ code: "custom", message: `Too many images have been included, the max is ${MAX_IMAGES_PER_POST}`, path: ["embeds"] }); }
// Someone is being naughty if they get to this check if (linkCount > 0) { ctx.addIssue({ code: "custom", message: "Embed links cannot be included with images", path: ["embeds"] }); } }
// Check for number of post records if (recordCount > MAX_RECORDS_PER_POST) { ctx.addIssue({ code: "custom", message: "Too many post records have been included", path: ["embeds"] }); }
// Check for number of post records if (linkCount > MAX_WEBLINKS_PER_POST) { ctx.addIssue({ code: "custom", message: "Too many weblink embeds have been included", path: ["embeds"] }); } }});
export const EditSchema = z.object({ ...TextContent.shape, altEdits: z.object({ ...FileContentSchema.shape, ...AltTextSchema.shape }).array().max(MAX_EMBEDS_PER_POST, "too many items were provided").optional()});