見出し画像

GASからBlueSkyに投稿してみる

 タスクを先延ばししていたら4月に入っていたYo!

 BlueSky用JavaScriptライブラリとしてはatprotoが公開されているが、atprotoをGASに載せるのがしんどいので自作。

 ちなみにRSSを取得して、投下するbotは既に動作している。画像は知らない。

 RSSから画像を添付するにはRSSのmedia:thumbnailを取得して、画像を自力で貼り付けないと行けない。Xみたいに自動取得はしない。ホームページから取得する場合は更に面倒。<meta property="og:image" >とか、<meta name="twitter:image" >とか、幾つ規格があるんだアレ。

https://docs.bsky.app/docs/advanced-guides/posts  サムネイルリンクの作り方は、書いてあるけど実装する気がしない。

既にやっているし。


  "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
      }
    }

前準備:
1.BlueSkyの設定画面からアプリパスワードを取得。botに使うパスワードにはアプリパスワードを使うこと(権限の設定ができないけど)

2.GASのスクリプト プロパティに以下を設定。

  • BLUESKY_USERNAME

  • BLUESKY_PASSWORD

  • BLUESKY_SERVER

 test()は、テスト用。postBSky(text[, opts])が本体。

opt = {images: [FileID, …]}

 textは投稿するtext用、imagesにFileID(配列で最大4)を設定するとイメージが添付できる。

 リッチテキストの解析は、atprotoからのコピペ。コピペしているのでライセンスのMIT/Apache-2.0もコピペ。

 bot用なのでリンクとハッシュタグの解析はするけどメンションの解析はしない(メンションは、恐らく先にユーザー情報を取得してこないと上手くいかない)

 そういえば、サロゲートペアのデバッグしていない。

 後、getImageTypeFromBytesは機能していない。unsigned intで実装したらgetBytesがsinged intで取得してくるから面倒になった。getAsでJPEGに変換して投下することにした。

test()の実行結果


/*
 *  Copyright ©2024 MITH@mmk Dual MIT/Apache-2.0 License
 *  https://opensource.org/license/mit
 *  https://www.apache.org/licenses/LICENSE-2.0
 */
// Google Apps Script
// This is a sample code to post a message to Bluesky.

function getGASProperties() {
  // gas properties
  return {
    identifier: PropertiesService.getScriptProperties().getProperty('BLUESKY_USERNAME'),
    password: PropertiesService.getScriptProperties().getProperty('BLUESKY_PASSWORD'),
    service: PropertiesService.getScriptProperties().getProperty('BLUESKY_SERVER')
  };
}


// regexes for rich text
const MENTION_REGEX = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
const URL_REGEX =
  /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
const TRAILING_PUNCTUATION_REGEX = /\p{P}+$/gu


/**
 * `\ufe0f` emoji modifier
 * `\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2` zero-width spaces (likely incomplete)
 */
const TAG_REGEX =
  /(^|\s)[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)?/gu

// from https://github.com/bluesky-social/atproto/blob/main/packages/api/src/rich-text/unicode.ts
class UnicodeString {

  getUtf8ByteLength(utf16) {
    let utf8 = 0;
    for (let i = 0; i < utf16.length; i++) {
      let char = utf16.charCodeAt(i);
      // is Surrogate Pair
      if (char >= 0xd800 && char <= 0xdbff) {
        i++;
        utf8 += 4;
        continue;
      }
      if (char <= 0x7f) {
        utf8 += 1;
      } else if (char <= 0x7ff) {
        utf8 += 2;
      } else if (char <= 0xffff) {
        utf8 += 3;
      } else {
        utf8 += 4;
      }  
    }
    return utf8;
  }

  constructor(utf16) {
    this.utf16 = utf16
    // utf8 is a Uint8Array
    let utf8 = [];
    for (let i = 0; i < utf16.length; i++) {
      let char = utf16.charCodeAt(i);
      // surrogate pair
      if (char >= 0xd800 && char <= 0xdbff) {
        i++;
        const char2 = utf16.charCodeAt(i);
        if (char2 >= 0xdc00 && char2 <= 0xdfff) {
          char = ((char - 0xd800) << 10) + (char2 - 0xdc00) + 0x10000;
        } else {
          throw new Error('Invalid UTF-16');
        }
      }
      if (char <= 0x7f) {
        utf8.push(char);
      } else if (char <= 0x7ff) {
        utf8.push(0xc0 | (char >> 6), 0x80 | (char & 0x3f));
      } else if (char <= 0xffff) {
        utf8.push(
          0xe0 | (char >> 12),
          0x80 | ((char >> 6) & 0x3f),
          0x80 | (char & 0x3f)
        );
      } else {
        utf8.push(
          0xf0 | (char >> 18),
          0x80 | ((char >> 12) & 0x3f),
          0x80 | ((char >> 6) & 0x3f),
          0x80 | (char & 0x3f)
        );
      }
    }
  }

  get length() {
    return this.utf8.byteLength
  }

  get graphemeLength() {
    if (!this._graphemeLen) {
      this._graphemeLen = graphemeLen(this.utf16)
    }
    return this._graphemeLen
  }

  slice(start, end) {
    return this.utf8.slice(start, end)
  }

  utf16IndexToUtf8Index(i) {
    return this.getUtf8ByteLength(this.utf16.slice(0, i));
  }

  toString() {
    return this.utf16
  }
}

function convertRichTextToFacets(text) {
  const string = new UnicodeString(text);
  const facets = [];
  let match;
  /*
  while ((match = MENTION_REGEX.exec(text))) {
    const start = string.utf16IndexToUtf8Index(match.index);
    const end = string.utf16IndexToUtf8Index(match.index + match[0].length);
    const username = match[3];
    // username to did
    const did = this.usernameToDid(username); // no implementation
    facets.push({
      index: { byteStart: start, byteEnd: end },
      features: [{ $type: 'app.bsky.richtext.facet#mention', did: match[3] }]
    });
  }
  */
  while ((match = URL_REGEX.exec(text))) {
    const start = string.utf16IndexToUtf8Index(match.index);
    const end = string.utf16IndexToUtf8Index(match.index + match[0].length);
    facets.push({
      index: { byteStart: start, byteEnd: end },
      features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[3] }]
    });
  }
  while ((match = TAG_REGEX.exec(text))) {
    const start = string.utf16IndexToUtf8Index(match.index);
    const end = string.utf16IndexToUtf8Index(match.index + match[0].length);
    const tag = match[2] || match[0].replace(TRAILING_PUNCTUATION_REGEX, '');
    facets.push({
      index: { byteStart: start, byteEnd: end },
      features: [{ $type: 'app.bsky.richtext.facet#tag', tag: tag}]
    });
  }
  return facets;
}

function loginBluesky(identifier, password, service) {
  const endpoint = '/xrpc/com.atproto.server.createSession';
  const url = service + endpoint;
  const body = JSON.stringify({
    identifier: identifier,
    password: password
  });
  const headers = {
    'Content-Type': 'application/json'
  };
  const response = UrlFetchApp.fetch(url, {
    method: 'POST',
    payload: body,
    headers: headers
  });

  if (response.getResponseCode() !== 200) {
    throw new Error(`Login Error ${response.getResponseCode()} ${response.getContentText()}`);
  }
  return JSON.parse(response.getContentText());
}

function getImageTypeFromBytes(bytes) {
  let type = '';
  if (bytes.length < 6) {
    throw new Error('Is this file is not image. < 6 bytes');
  }
  if (bytes.length >= 10000000) {
    throw new Error(`File size is too large, ${bytes.length} bytes.`);
  }
  // check format jpeg, png, gif
  if (bytes[0] == 0xff && bytes[1] == 0xd8) {
    // jpeg
    type = 'image/jpeg';
  } else if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47 && bytes[4] == 0x0d && bytes[5] == 0x0a && bytes[6] == 0x1a && bytes[7] == 0x0a) {
    // png
    type = 'image/png';
  } else if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 && (bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61) {
    // gif
    type = 'image/gif';
  } else {
    throw new Error('Unsupported image format');
  }
  return type;
}


function convertImageToBytes(imageId) {
  const image = DriveApp.getFileById(imageId).getAs('image/jpeg');
  const type = 'image.jpeg';
  return {bytes: image, type};
}


function uploadImage(session, image, type, service) {
  const endpoint = '/xrpc/com.atproto.repo.uploadBlob';
  const url = service + endpoint;

  const headers = {
    'Authorization': `Bearer ${session.accessJwt}`,
    'Content-Type': type
  };

  const response = UrlFetchApp.fetch(url, {
    method: 'POST',
    payload: image,
    headers,
  });

  if (response.getResponseCode() !== 200) {
    throw new Error(`Upload Image Error ${response.getResponseCode()} ${response.getContentText()}`);
  }

  return JSON.parse(response);
}


function postText(session, text, service, opts = {}) {
  const endpoint = '/xrpc/com.atproto.repo.createRecord';
  const url = service + endpoint;
  const createdAt = new Date().toISOString();
  let images = [];
  if (opts.images) {
    // load images and convert blob
    for (let i = 0; i < Math.min(opts.images.length, 4); i++) {
      const imageFilename = opts.images[i];
      const image = convertImageToBytes(imageFilename);
      // upload image
      const response = uploadImage(session, image.bytes, image.type, service);
      const blob = response;
      const alt = `image${i+1}`;
      images.push({
        alt: alt,
        image: blob.blob
      });
    }
  }

  // rich text parser is here
  // JavaScriptは、UTF-16 送信データはUTF-8なので注意
  const facets = convertRichTextToFacets(text);
  /* facets[]:
  [
    {
      index: {
        byteStart: linkStart,
        byteEnd: linkEnd,
      },
      features: [{ $type: "app.bsky.richtext.facet#link", uri: url }], // isLink
      // "#mention", "#link", "#tag
    },
  ]
  */
  record = {
    $type: 'app.bsky.feed.post',
    text,
    createdAt,  
  };

  // add facets
  if (facets.length > 0) {
    record.facets = facets;
  }

  // add images
  if (images.length > 0) {
    record.embed = {
      $type: 'app.bsky.embed.images',
      images
    }
  }

  const response = UrlFetchApp.fetch(url, {
    method: 'POST',
    payload: JSON.stringify({
      repo: session.did,
      collection: 'app.bsky.feed.post',
      record
    }),
    accept: 'application/json',
    headers: {
      'Authorization': `Bearer ${session.accessJwt}`,
      'Content-Type': 'application/json'
    }
  });
  if (response.getResponseCode() !== 200) {
    throw new Error(`Post Error ${response.getResponseCode()} ${response.getContentText()}`);
  }

  return JSON.parse(response.getContentText());
}

function postBSky(text, opts = {}) {
  const { identifier, password, service } = getGASProperties();
  const session = loginBluesky(identifier, password, service);
  postText(session, text, service, opts);
}

function test() {
  try {
    const text = 'GASから投稿してみる #AIイメージ'; // Post Text
    const fileimages = ['1fhryCK-0hISgs0CDHTo94APCVxSPnSyX']; // File ID
    const opts = {images: fileimages}; // option for with Image
    postBSky(text,opts); // post with image
    // postBSky(text); // post no image
  } catch (error) {
    Logger.log(error);
  }
}

#BlueSky #プログラミング #JavaScript #GAS

この記事が気に入ったらサポートをしてみませんか?