feat: added bskyPostRef
2ec4b3cb
5 file(s) · +310 −8
| 15 | 15 | import { findConfig, generateConfigTemplate } from "../lib/config"; |
|
| 16 | 16 | import { loadCredentials } from "../lib/credentials"; |
|
| 17 | 17 | import { createAgent, createPublication } from "../lib/atproto"; |
|
| 18 | - | import type { FrontmatterMapping } from "../lib/types"; |
|
| 18 | + | import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; |
|
| 19 | 19 | ||
| 20 | 20 | async function fileExists(filePath: string): Promise<boolean> { |
|
| 21 | 21 | try { |
|
| 263 | 263 | publicationUri = uri as string; |
|
| 264 | 264 | } |
|
| 265 | 265 | ||
| 266 | + | // Bluesky posting configuration |
|
| 267 | + | const enableBluesky = await confirm({ |
|
| 268 | + | message: "Enable automatic Bluesky posting when publishing?", |
|
| 269 | + | initialValue: false, |
|
| 270 | + | }); |
|
| 271 | + | ||
| 272 | + | if (enableBluesky === Symbol.for("cancel")) { |
|
| 273 | + | onCancel(); |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | let blueskyConfig: BlueskyConfig | undefined; |
|
| 277 | + | if (enableBluesky) { |
|
| 278 | + | const maxAgeDaysInput = await text({ |
|
| 279 | + | message: "Maximum age (in days) for posts to be shared on Bluesky:", |
|
| 280 | + | defaultValue: "7", |
|
| 281 | + | placeholder: "7", |
|
| 282 | + | validate: (value) => { |
|
| 283 | + | const num = parseInt(value, 10); |
|
| 284 | + | if (isNaN(num) || num < 1) { |
|
| 285 | + | return "Please enter a positive number"; |
|
| 286 | + | } |
|
| 287 | + | }, |
|
| 288 | + | }); |
|
| 289 | + | ||
| 290 | + | if (maxAgeDaysInput === Symbol.for("cancel")) { |
|
| 291 | + | onCancel(); |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | const maxAgeDays = parseInt(maxAgeDaysInput as string, 10); |
|
| 295 | + | blueskyConfig = { |
|
| 296 | + | enabled: true, |
|
| 297 | + | ...(maxAgeDays !== 7 && { maxAgeDays }), |
|
| 298 | + | }; |
|
| 299 | + | } |
|
| 300 | + | ||
| 266 | 301 | // Get PDS URL from credentials (already loaded earlier) |
|
| 267 | 302 | const pdsUrl = credentials?.pdsUrl; |
|
| 268 | 303 | ||
| 277 | 312 | publicationUri, |
|
| 278 | 313 | pdsUrl, |
|
| 279 | 314 | frontmatter: frontmatterMapping, |
|
| 315 | + | bluesky: blueskyConfig, |
|
| 280 | 316 | }); |
|
| 281 | 317 | ||
| 282 | 318 | const configPath = path.join(process.cwd(), "sequoia.json"); |
|
| 4 | 4 | import * as path from "path"; |
|
| 5 | 5 | import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; |
|
| 6 | 6 | import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; |
|
| 7 | - | import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto"; |
|
| 7 | + | import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto"; |
|
| 8 | 8 | import { |
|
| 9 | 9 | scanContentDirectory, |
|
| 10 | 10 | getContentHash, |
|
| 11 | 11 | updateFrontmatterWithAtUri, |
|
| 12 | 12 | } from "../lib/markdown"; |
|
| 13 | - | import type { BlogPost, BlobObject } from "../lib/types"; |
|
| 13 | + | import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; |
|
| 14 | 14 | import { exitOnCancel } from "../lib/prompts"; |
|
| 15 | 15 | ||
| 16 | 16 | export const publishCommand = command({ |
|
| 131 | 131 | } |
|
| 132 | 132 | ||
| 133 | 133 | log.info(`\n${postsToPublish.length} posts to publish:\n`); |
|
| 134 | + | ||
| 135 | + | // Bluesky posting configuration |
|
| 136 | + | const blueskyEnabled = config.bluesky?.enabled ?? false; |
|
| 137 | + | const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; |
|
| 138 | + | const cutoffDate = new Date(); |
|
| 139 | + | cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); |
|
| 140 | + | ||
| 134 | 141 | for (const { post, action, reason } of postsToPublish) { |
|
| 135 | 142 | const icon = action === "create" ? "+" : "~"; |
|
| 136 | - | log.message(` ${icon} ${post.frontmatter.title} (${reason})`); |
|
| 143 | + | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 144 | + | const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; |
|
| 145 | + | ||
| 146 | + | let bskyNote = ""; |
|
| 147 | + | if (blueskyEnabled) { |
|
| 148 | + | if (existingBskyPostRef) { |
|
| 149 | + | bskyNote = " [bsky: exists]"; |
|
| 150 | + | } else { |
|
| 151 | + | const publishDate = new Date(post.frontmatter.publishDate); |
|
| 152 | + | if (publishDate < cutoffDate) { |
|
| 153 | + | bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; |
|
| 154 | + | } else { |
|
| 155 | + | bskyNote = " [bsky: will post]"; |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); |
|
| 137 | 161 | } |
|
| 138 | 162 | ||
| 139 | 163 | if (dryRun) { |
|
| 164 | + | if (blueskyEnabled) { |
|
| 165 | + | log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); |
|
| 166 | + | } |
|
| 140 | 167 | log.info("\nDry run complete. No changes made."); |
|
| 141 | 168 | return; |
|
| 142 | 169 | } |
|
| 157 | 184 | let publishedCount = 0; |
|
| 158 | 185 | let updatedCount = 0; |
|
| 159 | 186 | let errorCount = 0; |
|
| 187 | + | let bskyPostCount = 0; |
|
| 160 | 188 | ||
| 161 | 189 | for (const { post, action } of postsToPublish) { |
|
| 162 | 190 | s.start(`Publishing: ${post.frontmatter.title}`); |
|
| 182 | 210 | } |
|
| 183 | 211 | } |
|
| 184 | 212 | ||
| 185 | - | // Track atUri and content for state saving |
|
| 213 | + | // Track atUri, content for state saving, and bskyPostRef |
|
| 186 | 214 | let atUri: string; |
|
| 187 | 215 | let contentForHash: string; |
|
| 216 | + | let bskyPostRef: StrongRef | undefined; |
|
| 217 | + | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 218 | + | ||
| 219 | + | // Check if bskyPostRef already exists in state |
|
| 220 | + | const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; |
|
| 188 | 221 | ||
| 189 | 222 | if (action === "create") { |
|
| 190 | 223 | atUri = await createDocument(agent, post, config, coverImage); |
|
| 208 | 241 | updatedCount++; |
|
| 209 | 242 | } |
|
| 210 | 243 | ||
| 244 | + | // Create Bluesky post if enabled and conditions are met |
|
| 245 | + | if (blueskyEnabled) { |
|
| 246 | + | if (existingBskyPostRef) { |
|
| 247 | + | log.info(` Bluesky post already exists, skipping`); |
|
| 248 | + | bskyPostRef = existingBskyPostRef; |
|
| 249 | + | } else { |
|
| 250 | + | const publishDate = new Date(post.frontmatter.publishDate); |
|
| 251 | + | ||
| 252 | + | if (publishDate < cutoffDate) { |
|
| 253 | + | log.info(` Post is older than ${maxAgeDays} days, skipping Bluesky post`); |
|
| 254 | + | } else { |
|
| 255 | + | // Create Bluesky post |
|
| 256 | + | try { |
|
| 257 | + | const pathPrefix = config.pathPrefix || "/posts"; |
|
| 258 | + | const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; |
|
| 259 | + | ||
| 260 | + | bskyPostRef = await createBlueskyPost(agent, { |
|
| 261 | + | title: post.frontmatter.title, |
|
| 262 | + | description: post.frontmatter.description, |
|
| 263 | + | canonicalUrl, |
|
| 264 | + | coverImage, |
|
| 265 | + | publishedAt: post.frontmatter.publishDate, |
|
| 266 | + | }); |
|
| 267 | + | ||
| 268 | + | // Update document record with bskyPostRef |
|
| 269 | + | await addBskyPostRefToDocument(agent, atUri, bskyPostRef); |
|
| 270 | + | log.info(` Created Bluesky post: ${bskyPostRef.uri}`); |
|
| 271 | + | bskyPostCount++; |
|
| 272 | + | } catch (bskyError) { |
|
| 273 | + | const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError); |
|
| 274 | + | log.warn(` Failed to create Bluesky post: ${errorMsg}`); |
|
| 275 | + | } |
|
| 276 | + | } |
|
| 277 | + | } |
|
| 278 | + | } |
|
| 279 | + | ||
| 211 | 280 | // Update state (use relative path from config directory) |
|
| 212 | 281 | const contentHash = await getContentHash(contentForHash); |
|
| 213 | - | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 214 | 282 | state.posts[relativeFilePath] = { |
|
| 215 | 283 | contentHash, |
|
| 216 | 284 | atUri, |
|
| 217 | 285 | lastPublished: new Date().toISOString(), |
|
| 286 | + | bskyPostRef, |
|
| 218 | 287 | }; |
|
| 219 | 288 | } catch (error) { |
|
| 220 | 289 | const errorMessage = error instanceof Error ? error.message : String(error); |
|
| 231 | 300 | log.message("\n---"); |
|
| 232 | 301 | log.info(`Published: ${publishedCount}`); |
|
| 233 | 302 | log.info(`Updated: ${updatedCount}`); |
|
| 303 | + | if (bskyPostCount > 0) { |
|
| 304 | + | log.info(`Bluesky posts: ${bskyPostCount}`); |
|
| 305 | + | } |
|
| 234 | 306 | if (errorCount > 0) { |
|
| 235 | 307 | log.warn(`Errors: ${errorCount}`); |
|
| 236 | 308 | } |
|
| 2 | 2 | import * as fs from "fs/promises"; |
|
| 3 | 3 | import * as path from "path"; |
|
| 4 | 4 | import * as mimeTypes from "mime-types"; |
|
| 5 | - | import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types"; |
|
| 5 | + | import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types"; |
|
| 6 | 6 | import { stripMarkdownForText } from "./markdown"; |
|
| 7 | 7 | ||
| 8 | 8 | async function fileExists(filePath: string): Promise<boolean> { |
|
| 352 | 352 | ||
| 353 | 353 | return response.data.uri; |
|
| 354 | 354 | } |
|
| 355 | + | ||
| 356 | + | // --- Bluesky Post Creation --- |
|
| 357 | + | ||
| 358 | + | export interface CreateBlueskyPostOptions { |
|
| 359 | + | title: string; |
|
| 360 | + | description?: string; |
|
| 361 | + | canonicalUrl: string; |
|
| 362 | + | coverImage?: BlobObject; |
|
| 363 | + | publishedAt: string; // Used as createdAt for the post |
|
| 364 | + | } |
|
| 365 | + | ||
| 366 | + | /** |
|
| 367 | + | * Count graphemes in a string (for Bluesky's 300 grapheme limit) |
|
| 368 | + | */ |
|
| 369 | + | function countGraphemes(str: string): number { |
|
| 370 | + | // Use Intl.Segmenter if available, otherwise fallback to spread operator |
|
| 371 | + | if (typeof Intl !== "undefined" && Intl.Segmenter) { |
|
| 372 | + | const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); |
|
| 373 | + | return [...segmenter.segment(str)].length; |
|
| 374 | + | } |
|
| 375 | + | return [...str].length; |
|
| 376 | + | } |
|
| 377 | + | ||
| 378 | + | /** |
|
| 379 | + | * Truncate a string to a maximum number of graphemes |
|
| 380 | + | */ |
|
| 381 | + | function truncateToGraphemes(str: string, maxGraphemes: number): string { |
|
| 382 | + | if (typeof Intl !== "undefined" && Intl.Segmenter) { |
|
| 383 | + | const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); |
|
| 384 | + | const segments = [...segmenter.segment(str)]; |
|
| 385 | + | if (segments.length <= maxGraphemes) return str; |
|
| 386 | + | return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "..."; |
|
| 387 | + | } |
|
| 388 | + | // Fallback |
|
| 389 | + | const chars = [...str]; |
|
| 390 | + | if (chars.length <= maxGraphemes) return str; |
|
| 391 | + | return chars.slice(0, maxGraphemes - 3).join("") + "..."; |
|
| 392 | + | } |
|
| 393 | + | ||
| 394 | + | /** |
|
| 395 | + | * Create a Bluesky post with external link embed |
|
| 396 | + | */ |
|
| 397 | + | export async function createBlueskyPost( |
|
| 398 | + | agent: AtpAgent, |
|
| 399 | + | options: CreateBlueskyPostOptions |
|
| 400 | + | ): Promise<StrongRef> { |
|
| 401 | + | const { title, description, canonicalUrl, coverImage, publishedAt } = options; |
|
| 402 | + | ||
| 403 | + | // Build post text: title + description + URL |
|
| 404 | + | // Max 300 graphemes for Bluesky posts |
|
| 405 | + | const MAX_GRAPHEMES = 300; |
|
| 406 | + | ||
| 407 | + | let postText: string; |
|
| 408 | + | const urlPart = `\n\n${canonicalUrl}`; |
|
| 409 | + | const urlGraphemes = countGraphemes(urlPart); |
|
| 410 | + | ||
| 411 | + | if (description) { |
|
| 412 | + | // Try: title + description + URL |
|
| 413 | + | const fullText = `${title}\n\n${description}${urlPart}`; |
|
| 414 | + | if (countGraphemes(fullText) <= MAX_GRAPHEMES) { |
|
| 415 | + | postText = fullText; |
|
| 416 | + | } else { |
|
| 417 | + | // Truncate description to fit |
|
| 418 | + | const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n"); |
|
| 419 | + | if (availableForDesc > 10) { |
|
| 420 | + | const truncatedDesc = truncateToGraphemes(description, availableForDesc); |
|
| 421 | + | postText = `${title}\n\n${truncatedDesc}${urlPart}`; |
|
| 422 | + | } else { |
|
| 423 | + | // Just title + URL |
|
| 424 | + | postText = `${title}${urlPart}`; |
|
| 425 | + | } |
|
| 426 | + | } |
|
| 427 | + | } else { |
|
| 428 | + | // Just title + URL |
|
| 429 | + | postText = `${title}${urlPart}`; |
|
| 430 | + | } |
|
| 431 | + | ||
| 432 | + | // Final truncation if still too long (shouldn't happen but safety check) |
|
| 433 | + | if (countGraphemes(postText) > MAX_GRAPHEMES) { |
|
| 434 | + | postText = truncateToGraphemes(postText, MAX_GRAPHEMES); |
|
| 435 | + | } |
|
| 436 | + | ||
| 437 | + | // Calculate byte indices for the URL facet |
|
| 438 | + | const encoder = new TextEncoder(); |
|
| 439 | + | const urlStartInText = postText.lastIndexOf(canonicalUrl); |
|
| 440 | + | const beforeUrl = postText.substring(0, urlStartInText); |
|
| 441 | + | const byteStart = encoder.encode(beforeUrl).length; |
|
| 442 | + | const byteEnd = byteStart + encoder.encode(canonicalUrl).length; |
|
| 443 | + | ||
| 444 | + | // Build facets for the URL link |
|
| 445 | + | const facets = [ |
|
| 446 | + | { |
|
| 447 | + | index: { |
|
| 448 | + | byteStart, |
|
| 449 | + | byteEnd, |
|
| 450 | + | }, |
|
| 451 | + | features: [ |
|
| 452 | + | { |
|
| 453 | + | $type: "app.bsky.richtext.facet#link", |
|
| 454 | + | uri: canonicalUrl, |
|
| 455 | + | }, |
|
| 456 | + | ], |
|
| 457 | + | }, |
|
| 458 | + | ]; |
|
| 459 | + | ||
| 460 | + | // Build external embed |
|
| 461 | + | const embed: Record<string, unknown> = { |
|
| 462 | + | $type: "app.bsky.embed.external", |
|
| 463 | + | external: { |
|
| 464 | + | uri: canonicalUrl, |
|
| 465 | + | title: title.substring(0, 500), // Max 500 chars for title |
|
| 466 | + | description: (description || "").substring(0, 1000), // Max 1000 chars for description |
|
| 467 | + | }, |
|
| 468 | + | }; |
|
| 469 | + | ||
| 470 | + | // Add thumbnail if coverImage is available |
|
| 471 | + | if (coverImage) { |
|
| 472 | + | (embed.external as Record<string, unknown>).thumb = coverImage; |
|
| 473 | + | } |
|
| 474 | + | ||
| 475 | + | // Create the post record |
|
| 476 | + | const record: Record<string, unknown> = { |
|
| 477 | + | $type: "app.bsky.feed.post", |
|
| 478 | + | text: postText, |
|
| 479 | + | facets, |
|
| 480 | + | embed, |
|
| 481 | + | createdAt: new Date(publishedAt).toISOString(), |
|
| 482 | + | }; |
|
| 483 | + | ||
| 484 | + | const response = await agent.com.atproto.repo.createRecord({ |
|
| 485 | + | repo: agent.session!.did, |
|
| 486 | + | collection: "app.bsky.feed.post", |
|
| 487 | + | record, |
|
| 488 | + | }); |
|
| 489 | + | ||
| 490 | + | return { |
|
| 491 | + | uri: response.data.uri, |
|
| 492 | + | cid: response.data.cid, |
|
| 493 | + | }; |
|
| 494 | + | } |
|
| 495 | + | ||
| 496 | + | /** |
|
| 497 | + | * Add bskyPostRef to an existing document record |
|
| 498 | + | */ |
|
| 499 | + | export async function addBskyPostRefToDocument( |
|
| 500 | + | agent: AtpAgent, |
|
| 501 | + | documentAtUri: string, |
|
| 502 | + | bskyPostRef: StrongRef |
|
| 503 | + | ): Promise<void> { |
|
| 504 | + | const parsed = parseAtUri(documentAtUri); |
|
| 505 | + | if (!parsed) { |
|
| 506 | + | throw new Error(`Invalid document URI: ${documentAtUri}`); |
|
| 507 | + | } |
|
| 508 | + | ||
| 509 | + | // Fetch existing record |
|
| 510 | + | const existingRecord = await agent.com.atproto.repo.getRecord({ |
|
| 511 | + | repo: parsed.did, |
|
| 512 | + | collection: parsed.collection, |
|
| 513 | + | rkey: parsed.rkey, |
|
| 514 | + | }); |
|
| 515 | + | ||
| 516 | + | // Add bskyPostRef to the record |
|
| 517 | + | const updatedRecord = { |
|
| 518 | + | ...(existingRecord.data.value as Record<string, unknown>), |
|
| 519 | + | bskyPostRef, |
|
| 520 | + | }; |
|
| 521 | + | ||
| 522 | + | // Update the record |
|
| 523 | + | await agent.com.atproto.repo.putRecord({ |
|
| 524 | + | repo: parsed.did, |
|
| 525 | + | collection: parsed.collection, |
|
| 526 | + | rkey: parsed.rkey, |
|
| 527 | + | record: updatedRecord, |
|
| 528 | + | }); |
|
| 529 | + | } |
|
| 1 | 1 | import * as fs from "fs/promises"; |
|
| 2 | 2 | import * as path from "path"; |
|
| 3 | - | import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types"; |
|
| 3 | + | import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types"; |
|
| 4 | 4 | ||
| 5 | 5 | const CONFIG_FILENAME = "sequoia.json"; |
|
| 6 | 6 | const STATE_FILENAME = ".sequoia-state.json"; |
|
| 76 | 76 | pdsUrl?: string; |
|
| 77 | 77 | frontmatter?: FrontmatterMapping; |
|
| 78 | 78 | ignore?: string[]; |
|
| 79 | + | bluesky?: BlueskyConfig; |
|
| 79 | 80 | }): string { |
|
| 80 | 81 | const config: Record<string, unknown> = { |
|
| 81 | 82 | siteUrl: options.siteUrl, |
|
| 110 | 111 | ||
| 111 | 112 | if (options.ignore && options.ignore.length > 0) { |
|
| 112 | 113 | config.ignore = options.ignore; |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | if (options.bluesky) { |
|
| 117 | + | config.bluesky = options.bluesky; |
|
| 113 | 118 | } |
|
| 114 | 119 | ||
| 115 | 120 | return JSON.stringify(config, null, 2); |
|
| 6 | 6 | tags?: string; // Field name for tags (default: "tags") |
|
| 7 | 7 | } |
|
| 8 | 8 | ||
| 9 | + | // Strong reference for Bluesky post (com.atproto.repo.strongRef) |
|
| 10 | + | export interface StrongRef { |
|
| 11 | + | uri: string; // at:// URI format |
|
| 12 | + | cid: string; // Content ID |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | // Bluesky posting configuration |
|
| 16 | + | export interface BlueskyConfig { |
|
| 17 | + | enabled: boolean; |
|
| 18 | + | maxAgeDays?: number; // Only post if published within N days (default: 7) |
|
| 19 | + | } |
|
| 20 | + | ||
| 9 | 21 | export interface PublisherConfig { |
|
| 10 | 22 | siteUrl: string; |
|
| 11 | 23 | contentDir: string; |
|
| 18 | 30 | identity?: string; // Which stored identity to use (matches identifier) |
|
| 19 | 31 | frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings |
|
| 20 | 32 | ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) |
|
| 33 | + | bluesky?: BlueskyConfig; // Optional Bluesky posting configuration |
|
| 21 | 34 | } |
|
| 22 | 35 | ||
| 23 | 36 | export interface Credentials { |
|
| 62 | 75 | contentHash: string; |
|
| 63 | 76 | atUri?: string; |
|
| 64 | 77 | lastPublished?: string; |
|
| 78 | + | bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post |
|
| 65 | 79 | } |
|
| 66 | 80 | ||
| 67 | 81 | export interface PublicationRecord { |
|