Schedule posts to Bluesky with Cloudflare workers.
skyscheduler.work
cf
tool
bsky-tool
cloudflare
bluesky
schedule
bsky
service
social-media
cloudflare-workers
1import isEmpty from "just-is-empty";
2import * as z from "zod/v4";
3import { BSKY_VIDEO_LENGTH_LIMIT } from "../limits";
4import { EmbedDataType } from "../types";
5import { FileContentSchema } from "./mediaSchema";
6import { atpRecordURI } from "./regexCases";
7import { AltTextSchema } from "./sharedValidations";
8
9export const ImageEmbedSchema = z.object({
10 ...FileContentSchema.shape,
11 type: z.literal(EmbedDataType.Image),
12 ...AltTextSchema.shape,
13});
14
15export const VideoEmbedSchema = z.object({
16 ...FileContentSchema.shape,
17 type: z.literal(EmbedDataType.Video),
18 width: z.number("media width is not a number")
19 .min(1, "media width value is below 1")
20 .nonoptional("media width is required"),
21 height: z.number("media height is not a number")
22 .min(1, "media height value is below 1")
23 .nonoptional("media height is required"),
24 duration: z.number("media duration is invalid")
25 .min(0, "media must be over 0 seconds long")
26 .max(BSKY_VIDEO_LENGTH_LIMIT, `media must be less than ${BSKY_VIDEO_LENGTH_LIMIT} seconds long`)
27 .nonoptional("media duration is required")
28});
29
30export const LinkEmbedSchema = z.object({
31 /* content is the thumbnail */
32 content: z.string().trim().prefault("").refine((value) => {
33 if (isEmpty(value))
34 return true;
35 // So the idea here is to try to encode the string into an URL object, and if that fails
36 // then you just fail out the string.
37 try {
38 const urlWrap = new URL(value);
39 return urlWrap.protocol === "https:" || urlWrap.protocol === "http:";
40 } catch (err) {
41 return false;
42 }
43 }, {
44 message: "the link to embed failed to parse, is it accessible?",
45 path: ["content"]
46 }),
47 type: z.literal(EmbedDataType.WebLink),
48 title: z.string().trim().default(""),
49 /* NOTE: uri is the link to the website here,
50 content is used as the thumbnail */
51 uri: z.url({
52 normalize: true,
53 protocol: /^https?$/,
54 hostname: z.regexes.domain,
55 error: "provided link is not an URL, please check URL and try again"
56 }).trim()
57 .nonoptional("link embeds require a url"),
58 description: z.string().trim().default("")
59});
60
61export const PostRecordSchema = z.object({
62 content: z.url({
63 normalize: true,
64 protocol: /^https?$/,
65 hostname: z.regexes.domain,
66 error: "post/feed/list record url is invalid"
67 }).trim()
68 .toLowerCase()
69 .regex(atpRecordURI, "url is not a post/feed/list record")
70 .nonoptional("post/feed/list records require a url"),
71 type: z.literal(EmbedDataType.Record),
72});