Xに定期ポストするGASをTypeScriptで書いた
はじめに
最近、個人開発したAIチャットボットをプロモーションするために、X(旧Twitter)に定期的に投稿する仕組みが欲しいと思い、Google Apps Script(GAS)を使って自動投稿システムを開発しました。この記事では、その開発内容と実装上の工夫について紹介します。
ソースコードの全文はGitHubで公開していますので、同様のシステムを構築したい方はぜひ参考にしてみてください。
最後のまとめにも書いてますが、GASじゃなくてLambdaとかCloudflare Workersとかでやったほうが楽かもしれません。
作ったもの
今回開発したシステムは以下の機能を持っています:
- 毎日定時にXへ自動的に投稿
- 投稿内容(本文と画像リンク)はスプレッドシートに複数セット保存。画像本体はGoogleDriveに保存。
- GASがスプレッドシートからランダムに投稿内容を選び、X APIを使って画像付き投稿を実行
このシステムにより、AIチャットボットのプロモーションを自動化できました。
プロジェクト構成
プロジェクトは以下のような構成になっています:
.
├── src/ # ソースコード(TypeScript)
├── types/ # GASライブラリの型定義ファイル
└── dist/ # トランスパイル・バンドルされたコード(claspによってGASと同期)
TypeScriptで開発し、トランスパイルしたコードをGASにデプロイする形にしています。GASと実際に同期されるのはdist
ディレクトリ内のコードです。
開発環境とツール
claspについて
claspは、ローカル環境でGASの開発を行うためのツールです。主な利点は:
- JavaScriptで開発可能
- 手元のVSCodeなど好きなエディタでGAS開発ができる
- Gitなどのバージョン管理ツールと組み合わせやすい
今回のプロジェクトでは、TypeScriptで書いたコードをesbuildでJavaScriptにトランスパイルし、claspを使ってGASにデプロイしています。これにより、型安全性の恩恵を受けながら開発を進めることができました。
X APIとの連携
X APIは複数の認証形式(OAuth1.0a、App only、Basic authentication、OAuth2.0)があり、各エンドポイントで必要な認証方法が異なる点が複雑でした。
今回利用した主なエンドポイントとその認証方式は:
-
POST /2/tweets
: ツイート投稿(OAuth2.0認証が必要) -
POST /2/media/upload
: 画像アップロード(OAuth2.0認証が必要)
いずれもOAuth2.0の認証が必要でした。
ハマリポイントとしては、リフレッシュトークンを発行するにはoffline.access
スコープを指定する必要がある点です。これがないとリフレッシュトークンが発行されないので、アクセストークンの有効期限が切れた際に自動更新ができません。
主要なライブラリと実装
@types/google-apps-script
SpreadsheetApp
やPropertiesService
など、GASの機能を使う時に、その型情報があると補完が効くので助かります。@types/google-apps-scriptを使うと型情報を導入できます。
OAuth2ライブラリ
GASでOAuth2.0認証フローを実装するためにOAuth2ライブラリを利用しました。このライブラリを使うことで、X APIとの認証フローを大幅に簡略化できます。
// auth.ts
import { CLIENT_ID, CLIENT_SECRET } from "./constants";
export function getService_() {
if (!CLIENT_ID || !CLIENT_SECRET) {
throw new Error("CLIENT_ID or CLIENT_SECRET is not set.");
}
return OAuth2.createService("X")
.setAuthorizationBaseUrl("https://x.com/i/oauth2/authorize")
.setTokenUrl("https://api.x.com/2/oauth2/token")
.setClientId(CLIENT_ID)
.setClientSecret(CLIENT_SECRET)
.setCallbackFunction("authCallback")
.setPropertyStore(PropertiesService.getUserProperties())
.setScope("tweet.write tweet.read media.write users.read offline.access") // リフレッシュトークンを発行するにはoffline.accessが必要
.generateCodeVerifier()
.setTokenHeaders({
Authorization:
"Basic " + Utilities.base64Encode(`${CLIENT_ID}:${CLIENT_SECRET}`),
"Content-Type": "application/x-www-form-urlencoded",
});
}
export function authCallback(
request: Parameters<OAuth2.Service["handleCallback"]>[0],
) {
const service = getService_();
const isAuthorized = service.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput("認証が完了しました。");
} else {
return HtmlService.createHtmlOutput("認証に失敗しました。");
}
}
export function logout() {
let service = getService_();
service.reset();
}
ただし、実装中に一つハマったポイントがありました。当初、サービスとコールバックを定義すれば自動的に認証フローが開始されると思っていましたが、実際にはユーザーが認証を開始するUIが必要でした。
// auth.ts
// spreadsheetのサイドバーに認証を開始するリンクを表示する
export function showSidebar() {
let service = getService_();
if (!service.hasAccess()) {
let authorizationUrl = service.getAuthorizationUrl();
let template = HtmlService.createTemplate(
'<a href="<?= authorizationUrl ?>" target="_blank">Authorize</a>. Reopen the sidebar when the authorization is complete.',
);
template.authorizationUrl = authorizationUrl;
let page = template.evaluate();
SpreadsheetApp.getUi().showSidebar(page);
}
}
// sheet.ts
// サイドバーを表示するためのカスタムメニューを追加する
export function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu("カスタムメニュー")
.addItem("サイドバーを表示", "showSidebar")
.addToUi();
}
また、onOpen
などの関数はCode.tsでexport
しないと最終的なGASコードに残らないことも知っておく必要があります。
// code.ts
// X APIのOAuth2.0認証を行う際のコールバック関数
export { authCallback } from "./auth";
// X APIのOAuth2.0アクセストークンを破棄する
export { logout } from "./auth";
// X APIのOAuth2.0認証を行うためのサイドバーを表示する
export { showSidebar } from "./auth";
// スプレッドシートを開いた時に、カスタムメニューを表示する
export { onOpen } from "./sheet";
// メインの処理
export { main } from "./main";
一度認証が完了すると、アクセストークンとリフレッシュトークンがGASのPropertiesService
に保存され、以降はライブラリがトークンのリフレッシュを自動的に処理してくれます。setScope("tweet.write tweet.read media.write users.read offline.access")の部分。スコープにoffline.accessを含めないとリフレッシュトークンが発行されないので注意してください。
FetchAppライブラリ
X APIのmedia uploadにはMultipart/form-dataでリクエストする必要があるのですが、GAS標準のUrlFetchApp
はMultipart/form-dataのリクエストに対応していないため、FetchAppライブラリを使用しました。これにより、画像付き投稿に必要なフォームデータのリクエストが可能になります。
また、form-dataにappendするblobですが、GoogleDriveから取得したファイルのblobではなく、web純正のblobに作り直す必要があります。convertToJsBlob
がその処理です。
// tweet.ts
function convertToJsBlob(imageBlob: GoogleAppsScript.Base.Blob) {
return Utilities.newBlob(
imageBlob.getBytes(),
imageBlob.getContentType() ?? "",
imageBlob.getName() ?? "",
);
}
export function uploadImage(imageBlob: GoogleAppsScript.Base.Blob) {
const service = getService_();
if (!service.hasAccess()) {
throw new Error("Access token is not set.");
}
const url = `${X_API_BASE_URL}/media/upload`;
const jsBlob = convertToJsBlob(imageBlob);
const form = FetchApp.createFormData();
form.append("media", jsBlob as unknown as Blob);
const options: FetchApp.FetchParams = {
method: "post",
headers: {
Authorization: `Bearer ${service.getAccessToken()}`,
"Content-Type": "multipart/form-data",
},
body: form,
};
const response = FetchApp.fetch(url, options);
const data = JSON.parse(response.getContentText());
return data.id;
}
GASライブラリの型定義ファイル
TypeScriptでGAS開発を行う際、先ほどのライブラリの型定義が欲しくなります。本プロジェクトでは、ChatGPTを活用して.d.ts
ファイルを生成しました。ChatGPTにライブラリのGithubのURLを渡して、「型定義ファイルを作って」と言えば作ってくれます。typesディレクトリ内のファイルがそれです。これにより、コード補完やコンパイル時の型チェックが可能になり、開発効率が大幅に向上しました。
おわりに
GASとX APIを組み合わせることで、手間をかけずに定期的な情報発信ができるシステムを構築できました。特にOAuth2.0の認証フローを理解することが一番の難関でしたが、一度設定してしまえば安定して動作するようになります。
実装も中盤を過ぎたあたりで、これGASじゃなくてLambdaとかCloudflare Workersとかでやったほうが楽だったかもと気づきました。GoogleDriveの権限やGASの独特な癖とかに振り回されるくらいなら、Node.jsが素直に動く関数実行環境を選んだ方があれこれ考えなくていいと思います。ライブラリの型ファイル作成なんてそもそも不要ですし、oauth2.0フローの実現にも無駄に時間がかかってしまいました。
参考資料
GAS + Typescript のいい感じのビルド環境を整える
VSCodeでclaspを使ってGAS上のライブラリをコード補完させる #GoogleAppsScript - Qiita
Discussion