🦕

DenoでBlueskyに𝑹𝒊𝒄𝒉 𝒕𝒆𝒙𝒕を投稿する

2023/04/18に公開

先月より、Bluesky Socialを利用しています。かつてのThe Bird App[1]のような雰囲気があるSNSです。

https://bsky.app/

https://zenn.dev/kato_shinya/articles/lets-try-bluesky-social

Bluesky(というかAT Protocol)はまだβ版ではありますが、APIが公開されており、それを使用して投稿などを行うことが可能です。

https://atproto.com/

個人的に特徴的だと思ったのは、Rich textをサポートしていることです。
将来的にはboldやitalicなども追加される可能性があるようですが、現状はメンションや外部URLへのリンクを作る機能のみとなっています。

https://github.com/bluesky-social/atproto/tree/main/packages/api#rich-text

本記事ではDenoを使ってBlueskyへ𝑹𝒊𝒄𝒉な投稿を行う方法を説明します。

メンションする

基本のコードを示します。
@yui.bsky.socialへメンションを飛ばすコードです。

post.ts
import AtprotoAPI from "npm:@atproto/api";
const { BskyAgent, RichText } = AtprotoAPI;
import "https://deno.land/std/dotenv/load.ts";

const service = "https://bsky.social";
const agent = new BskyAgent({ service });

const identifier = Deno.env.get("BLUESKY_IDENTIFIER");
const password = Deno.env.get("BLUESKY_PASSWORD");
await agent.login({ identifier, password });

const text = "@yui.bsky.social こんにちは!"
const rt = new RichText({ text });

await rt.detectFacets(agent); // automatically detects mentions and links

await agent.post({
  $type: "app.bsky.feed.post",
  text: rt.text,
  facets: rt.facets,
});
console.log(rt.text, rt.facets)

認証にはdeno_std/dotenvを使っています。以下のようなenvファイルが必要です。

.env
BLUESKY_IDENTIFIER=xxx.bsky.social
BLUESKY_PASSWORD=xxxxxxxxxxxxxxx

重要なのはpost直前のawait rt.detectFacets(agent);です。これで生成されるrt.facetsをpostに使わないととメンションになりません(URL文字列は自動でリンクになるようですが、おそらくこれはクライアント側で処理されています)。
このfacetsというのがマークアップ的な情報となっています。

実行してみましょう。read/env/net権限が必要です。

❯ deno run --allow-read --allow-env --allow-net post.ts
@yui.bsky.social こんにちは! [
  {
    "$type": "app.bsky.richtext.facet",
    index: { byteStart: 0, byteEnd: 16 },
    features: [
      {
        "$type": "app.bsky.richtext.facet#mention",
        did: "did:plc:xxxxx"
      }
    ]
  }
]

表示されたとおり、facetsは配列で、各要素はindexとfeaturesを持ちます。
indexには適用される範囲を、featuresが実際に適用されるマークアップの内容を設定します。


投稿できた

facetの型定義はこちらにあります。

リンクを書き換える

facetsを手動で設定することで、URL文字列と実際のリンク先を変えることができます。
ひとまず、文字列の最初から最後までを範囲として設定してみましょう。

post.ts
// agent.loginまでは共通なので省略

const text = "https://twitter.com"
const facets = [
  {
    index: { byteStart: 0, byteEnd: text.length },
    features: [
      {
        $type: "app.bsky.richtext.facet#link",
        uri: "https://dic.nicovideo.jp/a/%E3%83%90%E3%83%BC%E3%83%9C%E3%83%B3%E3%83%8F%E3%82%A6%E3%82%B9",
      },
    ],
  },
]
// 手動でfacetsを設定したのでdetectFacetsは不要
await agent.post({
  $type: "app.bsky.feed.post",
  text,
  facets,
});


(画像じゃわからないけど)twitterと見せかけてそうではないリンク

リンクを踏むとこのページへ飛びます。

リンクを書き換える:日本語に対応させる

facetsの適用範囲は、byteStart/byteEndの名の通り、バイトインデックスで指定する必要があります。マルチバイト文字(日本語や絵文字)の混じった文章ではtext.lengthなどが正しく動作しません。


何文字目がリンクになっているのやら

これを防ぐには(new TextEncoder()).encode(string).byteLengthを使う必要があります。
直接インデックスを触るのは煩雑になるので、markdown形式[text](url)で書いた文字列から、表示される文字列とfacetsを取り出す関数を作りました。
渡された文字列をループして、markdownのリンク文字列を見つけたらfacetに取り出し、リンクがなくなるまで続けます。

post.ts
// agent.loginまでは共通なので省略

const convert = (src: string) => {
  const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/;
  const facets = [];

  while (true) {
    const links = src.match(mdLinkRegex);
    if (!links) {
      return { text: src, facets };
    }

    const [matched, anchor, uri] = links;
    src = src.replace(matched, anchor);

    const byteStart =
      (new TextEncoder()).encode(src.substring(0, links.index)).byteLength;
    const byteEnd = byteStart + (new TextEncoder()).encode(anchor).byteLength;

    facets.push({
      index: { byteStart, byteEnd },
      features: [{ $type: "app.bsky.richtext.facet#link", uri }],
    });
  }
};

const {text, facets} = convert("[markdown](https://commonmark.org/)形式でリンクを書くテスト: [🥳](https://bsky.app/)")
await agent.post({
  $type: "app.bsky.feed.post",
  text,
  facets,
});


実はemojiの方もリンクになってるよ

まとめ

参考

以下のリポジトリを参考にしました。

https://github.com/aliceisjustplaying/atproto-starter-kit

公式のドキュメントはこちら。

https://atproto.com/lexicons/app-bsky-richtext

コードを書く上ではドキュメントだけではわからない定義などがあったため、atoprotoパッケージの実装を直接確認しました。

https://github.com/bluesky-social/atproto/tree/main/packages/api

脚注
  1. Twitterを示す符牒 ↩︎

Discussion