CloudFlare Workers×Slack API×GitHub APIで技術記事ドラフト作成Botをつくってみた

## はじめに
みなさん、日々アウトプットできていますか？

技術記事執筆は、アイディアストックから公開レベルに落とし込むまでがハードルが高いなーと思っており、仕組み化したい気持ちでいました。
例えば、Slackからアイディアを送るだけで、記事ドラフトが自動生成されてGitHubに保存されるBotがあったらなと。
そんなある日、Cloudflare Workersを知りました。
あまりに簡単にデプロイできるので、サクッと作って、さらには記事にしてしまおう、ということで早速本題です。

## Botの概要

このBotは、Slackで送信したアイディアやテーマから、技術記事のドラフトを自動生成し、GitHubリポジトリに保存、さらに結果をSlackに通知するという一連の流れを自動化するものです。

Slack→OpenAI→GitHub→Slackのシンプルなパイプラインで、下記のような処理フローを実現します。

- Slackで記事テーマやアイディアを送信
- OpenAIがMarkdown形式で記事ドラフトを自動生成
- GitHubの指定リポジトリ（draft/ディレクトリ）にドラフトを保存
- 処理結果をSlackに自動通知


### 技術スタック

- **Cloudflare Workers**
サーバーレスでAPIエンドポイントを構築できるプラットフォームです。インフラの管理やサーバー運用が不要で、世界中のエッジで高速にリクエストを処理できるのが大きな特徴です。
今回のBotでは、すべてのロジックをCloudflare Workers上に集約しています。

- **Slack API**
- **OpenAI API**
- **GitHub API**

## 事前準備
以下の準備が必要です。
準備過程の説明は割愛します。
シークレット情報の取り扱いには十分注意しましょう。

- OpenAI APIキーの取得
    - https://openai.com/ja-JP/api/
    - OpenAI APIは従量課金制なので、利用料金には注意しましょう
    - 他の関連APIでも代替可能です
- Cloudflareアカウント登録
    - https://www.cloudflare.com/ja-jp/
    - Workersのデプロイやシークレット管理に利用します
- Slack Appの作成
    - https://api.slack.com/apps
    - `Create New App -> From Scratch`を選択
- GitHubにドラフト保存用リポジトリを作成しておく
- GitHubのトークンを取得
    - https://github.com/settings/personal-access-tokens
    - 前項で作成したリポジトリを選択
    - `Repository permissions -> Contents: Read And Write` を付与すればOKです

## 実装手順

### 1. Cloudflare Workers プロジェクトの作成

まずは、Cloudflare Workersのプロジェクトを作成します。
以下のコマンドを実行してください。

```bash
npm create cloudflare@latest -- draft-bot
```

コマンド実行後、いくつかの質問に答えます。

- In which directory do you want to create your application?
    - dir: `./draft-bot`
- What would you like to start with?
    - category: `Hello World example`
- Which template would you like to use?
    - type: `Worker only`
-  Which language do you want to use?
    -  lang: `TypeScript`
- Do you want to use git for version control?
    - `yes`
- Do you want to deploy your application?
    - `no`

これでテンプレートファイルのコピーや依存パッケージのインストールが自動で行われ、プロジェクトが作成されます。

### 2. ローカル開発サーバーの起動

開発サーバーを起動して、ローカルで動作確認ができます。

```bash
npx wrangler dev
```

起動後、`http://localhost:8787` でローカルサーバーが立ち上がります。
`Hello, World!`が確認できればOKです。

### 3. 実装
⚠️ この実装例では、Cloudflare Workersのエンドポイントがパブリックに公開されます。誰でもアクセスできてしまうため、別途Slackの署名検証などを追加する必要があります。記事ボリュームの都合上今回は省略しますが、後日関連記事を投稿する予定です。

```typescript:src/index.ts
interface Env {
	OPENAI_API_KEY: string;
	GITHUB_TOKEN: string;
	GITHUB_REPO: string;
	GITHUB_BRANCH: string;
}

/**
 * handler
 */
export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
		const url = new URL(request.url);

		if (request.method === 'POST' && url.pathname === '/slack/events') {
			const rawBody = await request.text();
			const params = new URLSearchParams(rawBody);
			const responseUrl = params.get('response_url') ?? '';
			const text = params.get('text') ?? '';

            // 非同期バックグラウンド実行
			ctx.waitUntil(handleSlackEvent(text, responseUrl, env));
            // 即時レスポンス（Slackタイムアウト回避）
			return new Response(`✏️プロンプトを受け取りました。記事生成中です。\nprompt:\n${text}`);
		}

		return new Response('Not Found', { status: 404 });
	},
} satisfies ExportedHandler<Env>;

/**
 * 非同期処理本体
 */
async function handleSlackEvent(prompt: string, responseUrl: string, env: Env): Promise<void> {
	try {
		const { title, body } = await generateArticle(prompt, env.OPENAI_API_KEY);
		const resultMessage = await uploadToGitHub(title, body, env);
		if (responseUrl) await notifySlack(responseUrl, resultMessage);
	} catch (err) {
		console.error('処理失敗:', err);
		if (responseUrl) {
			await notifySlack(responseUrl, `❌ エラーが発生しました: ${(err as Error).message}`);
		}
	}
}

/**
 * systemプロンプト
 */
const SYSTEM_PROMPT = `
あなたは技術記事執筆アシスタントです。
以下のルールに従って、Markdown形式で技術記事のドラフトを生成してください：

- 記事の最初に必ず1つだけ H1 見出し（タイトル）を記載してください（例：# Cloudflare WorkersでBotを作ってみた）。
- 以降はH2見出し（##）を使ってセクションを整理してください。
- 挨拶文や前置きは省いて、記事の内容を直接書いてください。
`;

/**
 * 記事を生成する
 * @param prompt 記事の内容
 * @param apiKey OpenAIのAPIキー
 * @returns 記事のタイトルと本文
 */
async function generateArticle(prompt: string, apiKey: string): Promise<{ title: string; body: string }> {
	const response = await fetch('https://api.openai.com/v1/chat/completions', {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${apiKey}`,
			'Content-Type': 'application/json',
		},
		body: JSON.stringify({
			model: 'gpt-4o',
			messages: [
				{
					role: 'system',
					content: SYSTEM_PROMPT,
				},
				{
					role: 'user',
					content: `以下の内容の技術記事のドラフトを作成してください。\n${prompt}`,
				},
			],
		}),
	});

	const data = await response.json();
	const content = data.choices?.[0]?.message?.content ?? '';
	const [firstLine, ...rest] = content.split('\n');

	const title = firstLine.replace(/^# /, '').trim();
	const body = rest.join('\n').trim();

	return { title, body };
}

/**
 * GitHubに記事をアップロード
 * @param title 記事のタイトル
 * @param body 記事の本文
 * @param env 環境変数
 * @returns アップロード結果
 */
async function uploadToGitHub(title: string, body: string, env: Env): Promise<string> {
	const filePath = `draft/${title}.md`;
	const [owner, repo] = env.GITHUB_REPO.split('/');

	const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, {
		method: 'PUT',
		headers: {
			Authorization: `Bearer ${env.GITHUB_TOKEN}`,
			'Content-Type': 'application/json',
			Accept: 'application/vnd.github+json',
			'User-Agent': 'tech-blog-draft-bot',
		},
		body: JSON.stringify({
			message: `Add draft: ${filePath}`,
			content: btoa(String.fromCharCode(...new TextEncoder().encode(body))),
			branch: env.GITHUB_BRANCH,
		}),
	});

	if (!res.ok) {
		const text = await res.text();
		throw new Error(`GitHub投稿失敗: ${text}`);
	}

	return `✅ 記事をGitHubに投稿しました: ${title}`;
}

/**
 * Slackに通知
 * @param responseUrl SlackのWebhook URL
 * @param message 通知メッセージ
 */
async function notifySlack(responseUrl: string, message: string): Promise<void> {
	const res = await fetch(responseUrl, {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ text: message }),
	});

	if (!res.ok) {
		console.warn('Slack通知失敗');
	}
}
```

#### 補足
Slackからのリクエストに対して即時レスポンスすることで、タイムアウトを回避し、`ctx.waitUntil`でバックグラウンドで非同期処理を開始させています

### 4. 環境変数の設定（wrangler.jsonc）
下記を参考に、`wrangler.jsonc`にGitHubのリポジトリ・ブランチ情報を登録しておきます
デプロイ後、環境変数として設定されます
⚠️シークレット情報はあとで設定します、こちらには設定しないでください
```json:wrangler.jsonc
	// 環境変数
	"vars": {
		"GITHUB_REPO": "user-name/repo", // 記事をストックしたいrepoを設定
		"GITHUB_BRANCH": "main"
	}
```

### 5. デプロイ

準備ができたら、以下のコマンドでCloudflare Workersにデプロイします。

```bash
npx wrangler deploy
```
ターミナルにエンドポイントが表示されるのでメモしておきます。

### 6. 環境変数の設定（シークレット情報）
OpenAI APIキーやGitHubトークンなどのシークレット情報をCloudflareに登録します。

```bash
npx wrangler secret put OPENAI_API_KEY
npx wrangler secret put GITHUB_TOKEN
```

### 7. ダッシュボードを確認
デプロイされていることが確認できました。
![mosaic_20250520180138.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1868170/086b55fa-f283-422e-b431-f60c1c6fb8b8.png)

環境変数も設定されていることがわかります。
![mosaic_20250520180215.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1868170/c91f9f72-1818-40c6-99da-1b72922d9ea4.png)

### 8. Slack AppにSlash Commandsを設定
https://api.slack.com/apps から当該Appを選択し、`Slash Commands -> Create New Command`より下記情報を設定します

- Command: `draft-bot`
- Request URL: `{先ほどメモしたエンドポイント}/slack/events`
- Short Description: Appの説明

設定後、Appを追加したワークスペースで`/draft-bot`と入力すれば呼び出せるようになります

## 動作確認
### メッセージをBotに送信
![スクリーンショット 2025-05-20 17.34.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1868170/7da5fef3-0c15-41dc-bfe8-6129d5124b83.png)

### 記事を書き始めた
![スクリーンショット 2025-05-20 17.37.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1868170/eb60220f-9760-4110-94f3-5bf34167163f.png)

### Slackメッセージを受信
できたみたい
![スクリーンショット 2025-05-20 17.36.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1868170/83f1219d-691d-43bd-9fbe-dac5e9c1536e.png)

### GitHubを見てみると
記事ができてる！
![スクリーンショット 2025-05-19 22.39.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1868170/79964673-c803-4a44-baa7-fba672d98f85.png)

内容は試してみてのお楽しみです！

## まとめ
ざっくり動くまで30分程度でできました。記事を書くのにかかった時間より圧倒的に短いです。

Cloudflare Workersは今回初めて触りましたが、手軽でUIもわかりやすく、少ないリクエスト数であれば無料で使えるため、個人用途やPoC実装に使えそうだなという所感です。
この記事を読んで気になった方は是非触ってみてください。

良きアウトプットライフを！

---

## 参考資料
- https://developers.cloudflare.com/workers/
- https://api.slack.com/authentication/verifying-requests-from-slack

## ⚠️ 記事執筆のAI活用について
Qiitaガイドラインでは記事執筆へのAI活用は禁止されてはいないものの、内容には責任を持つように注意書きがあります。本Botはあくまでドラフト記事を生成するためのものであり、全面的なAI代筆を促す目的ではありません。
自動化して浮いた時間でより価値あるアウトプットをしていきたいですね💪