kintoneGeeks blog

kintoneに関連する情報を発信しています

Blueskyに自動投稿するBotを作成してRenderにデプロイするまで

はじめに

こんにちは!サイボウズの Developer Pioneer チームです。 少し前に、分散型 SNS の Bluesky が招待制ではなく、一般公開されましたね。この記事では、自動投稿をする Bluesky Bot を作成し、Render にデプロイする方法を説明します。投稿する内容は kintone で管理します。

全体像

まずは、全体像の確認です。定期的に kintone アプリを確認し、該当のレコードがあれば Bluesky に自動で投稿する仕組みです。

Bluesky に投稿する際に、URL のリンクカード(サムネイル画像)の設定部分がややこしいため、少しでも参考になれば嬉しいです。

シナリオの全体像
この記事で紹介するシナリオの全体像です

  1. 30 分に 1 回定期実行する
  2. kintone から該当のレコードデータを取得
    「公開済」かつ「公開日」が空欄のレコードを 1 件取得する
  3. kintone から 1 件レコードデータが返る
  4. 3 で取得したブログの URL からリンクカードの画像ファイルのリンクを取得
  5. 画像リンクから画像のバイナリファイルを作成
  6. Blob フィアルを Bluesky にアップロード
  7. アップロード成功時に blob のレスポンスが返る
  8. Bluesky の Feed に投稿する
  9. 投稿成功のレスポンスが返る
  10. kintone の公開日を更新する
  11. 更新成功のレスポンスが返る

定期実行 Bot を作成する

ローカル編

Bluesky Bot を準備する

まずは、ローカル環境で Bluesky に定期実行する Bot を作成します。 難しいことはありません。なんと、Bluesky の公式ドキュメントに Bot の Starter Template があるのでこれを使います。

Bluesky Bot Tutorial をローカルに Clone して、README の Set Up の手順通りにnpm i -g typescriptnpm i -g ts-node を実行します。 また、example.envのファイル名を.envに変更し、環境変数を定義しておきます。

環境変数が設定できたら、ts-node index.ts でコードを実行してみます。絵文字が投稿できていれば問題ありません。

Botの実行結果
Botの実行結果

そのままコマンドを実行したままにしておくと、3 時間に 1 回定期実行してくれます。

ポイント解説

コードを見てみると、Bluesky の API クライアント(GitHub - bluesky-social/atproto: Social networking technology created by Bluesky)を用いて、Bluesky への認証と投稿を行っていることがわかります。簡単そうですね!

kintoneと連携してみる

💡 kintone の環境を持っていない方
kintone 開発者ライセンス(開発環境) - cybozu developer network より無料の kintone 開発環境を取得することができます

kintone にブログ記事管理アプリを作成する

このアプリは kintone 上で、ブログの内容やレビュー、公開ステータスを管理するアプリを想定しています。実際に、普段から我々のチームでは同様のアプリを活用しています。それでは早速、アプリをはじめから作成する | kintone ヘルプを参考に下記のフィールドを含めたアプリを作成します。

フィールド項目 フィールド名 フィールドコード
文字列(1 行) タイトル title
ユーザー選択 執筆者 writer
ユーザー選択 レビュワー reviewer
文字列(複数行) 詳細 detail
日付 公開日 release_date
リンク 公開リンク release_link

このようにアプリの項目を設定しました。

ブログ管理アプリの例

アプリにプロセス管理を設定する

作成したブログ記事管理アプリにプロセス管理を設定して、担当者に通知を飛ばしたり、公開ステータスを設定していきます。

プロセス管理の基本的な使いかた | kintone ヘルプを参考に、プロセス管理タブを表示し、プロセス管理を有効にします。プロセスの詳細設定は画像を参照してください。

プロセス管理の詳細設定
プロセス管理の詳細設定

項目を設定できたら、右下の「保存」ボタンをクリックし、「アプリを更新」します。

kintone にデータを登録する

レコードを追加する | kintone ヘルプを参考に、2,3 件データを登録します。先ほど設定したプロセスも進めておきましょう。

「着手する」をクリック

「レビューを依頼する」→「LGTM」→「公開しました!」まで進めて OK です。

「公開済」までプロセスを進めておきます

kintone からレコードデータを取得して、Bluesky に自動投稿する

先ほどの index.ts ファイルに、kintone と連携するコードを追加します。Bluesky の API クライアントと同様に、kintone にも REST API クライアントが用意されています。

まずは、npm install @kintone/rest-api-clientを実行しライブラリをインストールします。

次に、kintone の API トークンを発行します。 APIトークンを生成する | kintone ヘルプの手順通りに、トークンを発行します。アクセス権は、レコード閲覧レコード編集にチェックをつけておきます。 生成した API トークンをコピーし、.envファイルに環境変数を追加します。

KINTONE_URL= "https://sample.cybozu.com" #自分のkintone環境のURL
KINTONE_API_TOKEN= "7pIhkzSSSX2LlS17sgDHP" #コピーしたAPI トークン
APP_ID= 1 #https://sample.cybozu.com/k/xxx/ の xxx 部分に表示されているアプリID

次に index.ts を編集します。

import { BskyAgent } from "@atproto/api";
import { CronJob } from "cron";
import { KintoneRestAPIClient, KintoneRecordField } from "@kintone/rest-api-client";
import * as process from "process";
import * as dotenv from "dotenv";
dotenv.config();

// **************
// kintoneアプリの型定義
// **************
type BlogApp = {
  $id: KintoneRecordField.ID;
  $revision: KintoneRecordField.Revision;
  更新者: KintoneRecordField.Modifier;
  作成者: KintoneRecordField.Creator;
  レコード番号: KintoneRecordField.RecordNumber;
  更新日時: KintoneRecordField.UpdatedTime;
  作成日時: KintoneRecordField.CreatedTime;
  title: KintoneRecordField.SingleLineText;
  writer: KintoneRecordField.UserSelect;
  reviewer: KintoneRecordField.UserSelect;
  detail: KintoneRecordField.MultiLineText;
  release_date: KintoneRecordField.Date;
  release_link: KintoneRecordField.Link;
};

// Create a Bluesky Agent
const agent = new BskyAgent({
  service: "https://bsky.social",
});

const client = new KintoneRestAPIClient({
  baseUrl: process.env.KINTONE_URL,
  auth: {
    apiToken: process.env.KINTONE_API_TOKEN,
  },
});

// **************
// kintoneに合わせて日付をハイフン繋ぎのフォーマットに変更する
// **************
const formatDate = (date: Date): string => {
  return date
    .toLocaleDateString("ja-JP", {
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
    })
    .split("/")
    .join("-");
};

// **************
// kintoneからデータを1件取得
// ステータスで絞り込む
const getKintoneRecords = async (): Promise<BlogApp | null> => {
  try {
    const resp = await client.record.getRecords<BlogApp>({
      app: process.env.APP_ID!,
      query: 'ステータス in ("🎉公開済") and release_date = "" order by レコード番号 asc limit 1',
    });
    if (resp.records.length === 0) {
      console.log("No records");
      return null;
    } else {
      return resp.records[0];
    }
  } catch (err) {
    console.error("Error get kintone records:", err);
    return null;
  }
};

// **************
// kintoneの公開日を更新する
// **************
const updateKintoneRecord = async (app_id: string, today: string): Promise<any> => {
  try {
    const resp = await client.record.updateRecord({
      app: process.env.APP_ID!,
      id: app_id,
      record: {
        release_date: {
          value: today,
        },
      },
    });
    return resp;
  } catch (err) {
    console.error("Error update kintone:", err);
    throw err;
  }
};

// **************
// BlueSkyに投稿する
// **************
const postToBlueSky = async (record: BlogApp): Promise<any> => {
  try {
    // bluesky APIクライアントにログイン
    await agent.login({
      identifier: process.env.BLUESKY_USERNAME!,
      password: process.env.BLUESKY_PASSWORD!,
    });

    // 日本語のマルチバイトに対応させる
    const endCounts: number =
      record.title.value === null ? 0 : new TextEncoder().encode(record.title.value).byteLength;

    // ポストの作成
    const postBlueSkyFeed = await agent.post({
      text: `${record.title.value} を公開しました🎉`,
      facets: [
        {
          index: {
            byteStart: 0, // リンクの開始位置
            byteEnd: endCounts, // リンクの終了位置
          },
          features: [
            {
              $type: "app.bsky.richtext.facet#link",
              uri: record.release_link.value,
            },
          ],
        },
      ],
    });
    return postBlueSkyFeed;
  } catch (err) {
    console.error("Error post feed to bluesky:", err);
    throw err;
  }
};

// **************
// メイン関数
// **************
async function main() {
  try {
    const today = formatDate(new Date());
    const record = await getKintoneRecords();
    if (!record) return;
    const postResponse = await postToBlueSky(record);
    console.log("Just posted!");
    console.log(postResponse);
    const updateRecord = await updateKintoneRecord(record.$id.value, today);
    console.log("Update kintone record!");
    console.log(updateRecord);
    return;
  } catch (error) {
    console.log(error);
  }
}

main();

// **************
// 定期実行の設定
// **************
const scheduleExpression = "0 */1 * * *"; // 1時間に1回実行する
const job = new CronJob(scheduleExpression, main);
job.start();

ts-node index.tsコマンドでコードを実行し、投稿されていれば成功です。

kintoneに登録された内容が投稿されている

ポイント解説

Bluesky に URL を含めたテキストを投稿する際、text にそのままリンクを入れてもリンク化されません。そのため、パラメータに facets をセットして、リンクにしたい文字列の開始位置と終了位置を設定する必要があります。また、文字数は日本語のマルチバイトに対応させる必要があるため、Uint8Array.prototype.byteLengthを使用して文字数を求めています。

ブログのリンクカード(サムネイル画像)を投稿する

このままでも良いですが、ブログのURLを投稿しているのでリンクカードを表示させてみます。

ウェブサイトのリンクカードを含めたい場合は、画像の blob を post 時のパラメータに含めるよう変更する必要があります。
公式ドキュメント:Creating a post | Bluesky

必要なパラメータ

{
  "$type": "app.bsky.feed.post",
  "text": "post which embeds an external URL as a card",
  "embed": {
    "$type": "app.bsky.embed.external",
    "external": {
      "uri": "https://bsky.app",
      "title": "Bluesky Social",
      "description": "See what's next.",
      "thumb": {
        "$type": "blob",
        "ref": {
          "$link": "bafkreiash5eihfku2jg4skhyh5kes7j5d5fd6xxloaytdywcvb3r3zrzhu"
        },
        "mimeType": "image/png",
        "size": 23527
      }
    }
  }
}

thumb に渡す値($link, mimeType, size など)を得るためには、① リンクカードにしたい画像を取得 ② 画像を Bluesky にアップロードする ③リンクカード(サムネイル画像)を投稿のパラメータに入れる 手順が必要です。

① リンクカードにしたい画像を取得

jsdom - npm という Nodejs で HTML テキストを操作できるライブラリを使用して、ブログの URL からリンクカードの画像リンクを取得します。npm i jsdomコマンドからライブラリをインストールします。

また、sharp - npm という画像処理のライブラリを使用して画像を加工します。同様に、npm i sharpコマンドからライブラリをインストールします。

:()
import { JSDOM } from 'jsdom';
import sharp from "sharp";
:

// **************
// ブログのリンクから画像URLを取得
// @url: ブログリンク
// **************
const getOgpCardImageLink = async (url: string): Promise<string | undefined> => {
  const card: any = {
    uri: url,
    title: "",
    description: "",
    thumb: undefined,
  };

  try {
    const response = await fetch(url);
    const html = await response.text();
    const dom = new JSDOM(html);
    const metaTags = dom.window.document.querySelectorAll('meta[property^="og:"]');

    // メタタグを解析
    metaTags.forEach((tag) => {
      if (tag.getAttribute("property") === "og:title") {
        card.title = tag.getAttribute("content") || "";
      } else if (tag.getAttribute("property") === "og:description") {
        card.description = tag.getAttribute("content") || "";
      } else if (tag.getAttribute("property") === "og:image") {
        card.thumb = tag.getAttribute("content") || "";
      }

      if (card.thumb) {
        if (!card.thumb.includes("://")) {
          card.thumb = new URL(card.thumb, url).href;
        }
        return card.thumb // 1件のみreturnする
      }
    });
    return card.thumb; //return 画像のリンク

  } catch (err) {
    console.error("Error fetching or parsing the URL:", err);
    throw err;
  }
};

// **************
// ブログのリンクから画像データを取得
// サイズを調整してバイナリファイルを生成
// @url: ブログ画像のリンク
// **************
const fetchImageBuffer = async (url: string): Promise<Buffer> => {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${response.statusText}`);
  }
  return Buffer.from(await response.arrayBuffer());
};

// **************
// 画像を加工する
// @imageBuffer: 画像のbuffer
// **************
const resizeImage = async (imageBuffer: Buffer): Promise<Buffer> => {
  const compressedImage = await sharp(imageBuffer)
  .resize(800, null, {
    fit: "inside",
    withoutEnlargement: true,
  })
  .jpeg({
    quality: 80,
    progressive: true,
  })
  .toBuffer();

  //値: <Buffer ff d8 xx xx ...>
  return compressedImage;
};

② 画像を Bluesky にアップロードする

// **************
// 画像ファイルをblueskyにアップロード
// **************
const uploadImageBlobToBlueSky = async (imageBuffer: Buffer): Promise<any> => {
  // bluesky APIクライアントにログイン
  await agent.login({
    identifier: process.env.BLUESKY_USERNAME!,
    password: process.env.BLUESKY_PASSWORD!,
  });

  const { data } = await agent.uploadBlob(imageBuffer, { encoding: "image/jpeg" });
  return data.blob.original;
};

agent.uploadBlobが成功すると、レスポンスのdataに下記のようなデータが含まれます。

{
  blob: BlobRef {
    ref: CID(xxxxxxxxxxxx),
    mimeType: 'image/jpeg',
    size: 39699,
    original: {
      '$type': 'blob',
      ref: CID(xxxxxxxxxxxx),
      mimeType: 'image/jpeg',
      size: 39699
    }
  }
}

Bluesky の投稿時のパラメータにdata.blob.original部分を使えばリンクカードを含めた投稿ができるようになります。

③リンクカード(サムネイル画像)を投稿のパラメータに入れる

postToBlueSky関数の中身を編集します。

// **************
// 投稿する
// @text: 表示テキスト
// @uel: リンク
// @uploadedImage: アップロードする画像オブジェクト
// **************
const postToBlueSky = async (record: BlogApp, thumb: string): Promise<any> => {
  try {
    // bluesky APIクライアントにログイン
    await agent.login({
      identifier: process.env.BLUESKY_USERNAME!,
      password: process.env.BLUESKY_PASSWORD!
    })

    // ポストの作成
    const postBlueSkyFeed = await agent.post({
      text: 'ブログ記事をアップしました!',
      embed: {
        $type: 'app.bsky.embed.external',
        external: {
          uri: record.release_link.value,
          title: record.title.value,
          description: "",
          thumb: thumb
        }
      }
    });
    return postBlueSkyFeed;
  } catch (err) {
    console.error("Error post feed to bluesky:", err);
    throw err;
  }
};

:

// **************
// メイン関数
// **************
async function main() {
  try {
    const today = formatDate(new Date());
    const record = await getKintoneRecords();
    if (!record) return;
    const imageLink = await getOgpCardImageLink(record.release_link.value!);
    if (!imageLink) throw new Error('OGP image link not found.');
    const imageBuffer = await fetchImageBuffer(imageLink);
    const resizedImage = await resizeImage(imageBuffer);
    const blobId = await uploadImageBlobToBlueSky(resizedImage);
    const postResponse = await postToBlueSky(record, blobId);
    const updateRecord = await updateKintoneRecord(record.$id.value, today);
    return;
  } catch (err) {
    console.error("Error main function:", err);
    throw err;
  }
}

main();

ts-node index.tsコマンドでコードを実行し、リンクカードが投稿されていれば成功です。

ブログのリンクカードが表示されている
ブログのリンクカードが確認できる

最終的なindex.tsコード

import { BskyAgent } from "@atproto/api";
import { CronJob } from "cron";
import { KintoneRestAPIClient, KintoneRecordField } from "@kintone/rest-api-client";
import { JSDOM } from "jsdom";
import sharp from "sharp";
import * as process from "process";
import * as dotenv from "dotenv";
dotenv.config();

// **************
// kintoneアプリの型定義
// **************
type BlogApp = {
  $id: KintoneRecordField.ID;
  $revision: KintoneRecordField.Revision;
  更新者: KintoneRecordField.Modifier;
  作成者: KintoneRecordField.Creator;
  レコード番号: KintoneRecordField.RecordNumber;
  更新日時: KintoneRecordField.UpdatedTime;
  作成日時: KintoneRecordField.CreatedTime;
  title: KintoneRecordField.SingleLineText;
  writer: KintoneRecordField.UserSelect;
  reviewer: KintoneRecordField.UserSelect;
  detail: KintoneRecordField.MultiLineText;
  release_date: KintoneRecordField.Date;
  release_link: KintoneRecordField.Link;
};

// Create a Bluesky Agent
const agent = new BskyAgent({
  service: "https://bsky.social",
});

const client = new KintoneRestAPIClient({
  baseUrl: process.env.KINTONE_URL,
  auth: {
    apiToken: process.env.KINTONE_API_TOKEN,
  },
});

// **************
// kintoneに合わせて日付をハイフン繋ぎのフォーマットに変更する
// **************
const formatDate = (date: Date): string => {
  return date
    .toLocaleDateString("ja-JP", {
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
    })
    .split("/")
    .join("-");
};

// **************
// kintoneからデータを1件取得
// ステータスで絞り込む
const getKintoneRecords = async (): Promise<BlogApp | null> => {
  try {
    const resp = await client.record.getRecords<BlogApp>({
      app: process.env.APP_ID!,
      query: 'ステータス in ("🎉公開済") and release_date = "" order by レコード番号 asc limit 1',
    });
    if (resp.records.length === 0) {
      console.log("No records");
      return null;
    } else {
      return resp.records[0];
    }
  } catch (err) {
    console.error("Error get kintone records:", err);
    return null;
  }
};

// **************
// kintoneの公開日を更新する
// **************
const updateKintoneRecord = async (app_id: string, today: string): Promise<any> => {
  try {
    const resp = await client.record.updateRecord({
      app: process.env.APP_ID!,
      id: app_id,
      record: {
        release_date: {
          value: today,
        },
      },
    });
    return resp;
  } catch (err) {
    console.error("Error update kintone:", err);
    throw err;
  }
};

// **************
// ブログのリンクから画像URLを取得
// @url: ブログリンク
// **************
const getOgpCardImageLink = async (url: string): Promise<string | undefined> => {
  const card: any = {
    uri: url,
    title: "",
    description: "",
    thumb: undefined,
  };

  try {
    const response = await fetch(url);
    const html = await response.text();
    const dom = new JSDOM(html);
    const metaTags = dom.window.document.querySelectorAll('meta[property^="og:"]');

    // メタタグを解析
    metaTags.forEach((tag) => {
      if (tag.getAttribute("property") === "og:title") {
        card.title = tag.getAttribute("content") || "";
      } else if (tag.getAttribute("property") === "og:description") {
        card.description = tag.getAttribute("content") || "";
      } else if (tag.getAttribute("property") === "og:image") {
        card.thumb = tag.getAttribute("content") || "";
      }

      if (card.thumb) {
        if (!card.thumb.includes("://")) {
          card.thumb = new URL(card.thumb, url).href;
        }
        return card.thumb; // 1件のみreturnする
      }
    });
    return card.thumb; //return 画像のリンク
  } catch (err) {
    console.error("Error fetching or parsing the URL:", err);
    throw err;
  }
};

// **************
// ブログのリンクから画像データを取得
// バイナリファイルを生成
// @url: ブログ画像のリンク
// **************
const fetchImageBuffer = async (url: string): Promise<Buffer> => {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${response.statusText}`);
  }
  return Buffer.from(await response.arrayBuffer());
};

// **************
// 画像を加工する
// @imageBuffer: 画像のbuffer
// **************
const resizeImage = async (imageBuffer: Buffer): Promise<Buffer> => {
  const compressedImage = await sharp(imageBuffer)
    .resize(800, null, {
      fit: "inside",
      withoutEnlargement: true,
    })
    .jpeg({
      quality: 80,
      progressive: true,
    })
    .toBuffer();

  //値: <Buffer ff d8 xx xx ...>
  return compressedImage;
};

// **************
// 画像ファイルをblueskyにアップロード
// **************
const uploadImageBlobToBlueSky = async (imageBuffer: Buffer): Promise<any> => {
  // bluesky APIクライアントにログイン
  await agent.login({
    identifier: process.env.BLUESKY_USERNAME!,
    password: process.env.BLUESKY_PASSWORD!,
  });

  const { data } = await agent.uploadBlob(imageBuffer, { encoding: "image/jpeg" });
  return data.blob.original;
};

// **************
// BlueSkyに投稿する
// **************
const postToBlueSky = async (record: BlogApp, thumb: string): Promise<any> => {
  try {
    // bluesky APIクライアントにログイン
    await agent.login({
      identifier: process.env.BLUESKY_USERNAME!,
      password: process.env.BLUESKY_PASSWORD!,
    });

    // ポストの作成
    const postBlueSkyFeed = await agent.post({
      text: "ブログ記事をアップしました!",
      embed: {
        $type: "app.bsky.embed.external",
        external: {
          uri: record.release_link.value,
          title: record.title.value,
          description: "",
          thumb: thumb,
        },
      },
    });
    return postBlueSkyFeed;
  } catch (err) {
    console.error("Error post feed to bluesky:", err);
    throw err;
  }
};

// **************
// メイン関数
// **************
async function main() {
  try {
    const today = formatDate(new Date());
    const record = await getKintoneRecords();
    if (!record) return;
    const imageLink = await getOgpCardImageLink(record.release_link.value!);
    if (!imageLink) throw new Error("OGP image link not found.");
    const imageBuffer = await fetchImageBuffer(imageLink);
    const resizedImage = await resizeImage(imageBuffer);
    const blobId = await uploadImageBlobToBlueSky(resizedImage);
    const postResponse = await postToBlueSky(record, blobId);
    const updateRecord = await updateKintoneRecord(record.$id.value, today);
    return;
  } catch (err) {
    console.error("Error main function:", err);
    throw err;
  }
}

main();

// **************
// 定期実行の設定
// **************
// const scheduleExpression = '0 */1 * * *'; // 1時間に1回実行する
// const job = new CronJob(scheduleExpression, main);
// job.start();

デプロイ編

作成したコードをデプロイしていきます。デプロイのプラットフォームには、Renderを使用します。Render は GitHub にコードを管理しておけば、かなり簡単にデプロイができます。

準備

Render にログインする前に、少し準備が必要です。

まずは、デプロイ先の Render で Cron が設定できるため、Cron 部分のコードをコメントアウトしておきます。

// **************
// 定期実行の設定
// **************
// const scheduleExpression = '0 */1 * * *'; // 1時間に1回実行する
// const job = new CronJob(scheduleExpression, main);
// job.start();

次に、npx tscを実行し、ts ファイルを js ファイルにコンパイルします。生成された js ファイルの動作検証のために、node index.js を実行し、正常に Bluesky に投稿されていることが確認できれば準備完了です。

準備ができらた、GitHub にレポジトリを作成し、コードをプッシュしておきます。node_modules/.envファイルをプッシュしないように注意しておきましょう。

Renderにログイン

Render にログインし、デプロイの設定をしていきます。

  1. Dashboard の NEW ボタンから、CronJobを選択する

  2. Build and deploy from a Git repository を選択する

  3. Connect a repository で該当のレポジトリを選択する
  4. 詳細を設定する
項目 注意
Name bsky-samplebot 名前は適宜変更してください
Region Singapore(Sourth Asia) リージョンは適宜変更してください
Branch main ブランチは適宜変更してください
Root Directory . ディレクトリは適宜変更してください
Runtime Node
Build Command npm install && npm i -g typescript && npm i -g ts-node ; npx tsc
Schedule _/30 _ * * *
30 分ごとに定期実行する
定期実行の間隔は適宜変更してください
Instance Type Starter
Command node index.js
Environment Variables 「Add from .env」より.env の情報を追加

Renderの設定項目
Renderの設定項目

値をセットできたら「Create Cron Job」からデプロイ完了をお待ちください。エラーログが出なければ、Render のデプロイも完了です。 もしデプロイが失敗した場合は、画面上のビルド時のログを確認してください。

デプロイ失敗時
デプロイに失敗した場合はログを確認します

デプロイ成功後に、Cron が動作していることと、Bluesky に投稿されていることを確認できれば問題ありません。

まとめ

文字列以外を API 経由で Bluesky に投稿する際は、少しパラメータの設定が必要になることが分かりました。

  • リンクを含めたい場合
  • リンクカード(サムネイル画像)を含めたい場合
    • 画像を一度 API 経由でアップロードしてから、レスポンスに含まれる blob($link, mimeType, size)を投稿の API のパラメータに渡す
    • Creating a post | Bluesky

今後また、Bluesky の他の API にも挑戦したいと思います!では!