SlackメッセージをS3にmarkdown形式で保存するシステムを作った

## 1. はじめに

私は日々のインプットのメモや雑記をSlackを使っています。しかし、多くのワークスペースに入っているとメモも散らばるし、何より後から見返しづらいです。

このプロジェクトでは、SlackのメッセージをMarkdown形式に整え、S3バケットに自動保存するシステムを構築しました。これによってSlackでのメモをひとまとめにしてS3に保存し、Obsidianなどのツールを使って見返すことができるようになります。

## 2. システム概要

これから説明するシステムは、以下のGitHubリポジトリのものです。お気軽に覗いてみてください！

https://github.com/KojoBarbie/slack-to-s3-md

### 2.1 アーキテクチャの全体像

このシステムは以下のコンポーネントで構成されています：

1. **Slack Bot**: チャンネルのメッセージを監視し、イベントを検知します
2. **API Gateway**: SlackからのWebhookを受け取り、Lambda関数を起動します
3. **Lambda関数**: メッセージを処理し、Markdown形式に変換します
4. **S3バケット**: 変換されたMarkdownファイルを保存します
5. **DynamoDB**: 処理済みイベントを記録し、重複処理を防止します

処理フローとしては、Slackでメッセージが投稿されると、設定したSlack Botがイベントを検知し、API Gateway経由でLambda関数を呼び出します。Lambda関数はメッセージをMarkdown形式に変換し、S3バケットの指定パスに保存します。その後、ObsidianのS3プラグインなどを通じて、これらのファイルをローカルのObsidianボルトに同期することができます。

![ChatGPT Image 2025年4月10日 02_31_55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671694/9f0ab8a3-e969-473e-a673-bac3eb7dfb51.png)

### 2.2 技術スタック

このプロジェクトでは、以下の技術スタックを使用しています：

- **バックエンド言語**: TypeScript
- **サーバーレス環境**: AWS Lambda
- **API**: AWS API Gateway
- **ストレージ**: AWS S3
- **データベース**: DynamoDB（重複排除用）
- **インフラ管理**: Terraform
- **イベントソース**: Slack API (Events API)

TypeScriptを使用することで、型安全性を確保しつつ、モダンなJavaScript機能を活用した開発が可能になりました。また、AWSのサーバーレスサービスを活用することで、インフラ管理の負担を最小限に抑えながら、拡張性の高いシステムを構築できました。

## 3. 技術の概要

このプロジェクトでは、以下の技術的なポイントに注目して実装を行いました：

- **サーバーレスアーキテクチャ**: コスト効率とスケーラビリティの観点から、AWS Lambdaを採用しました。
- **TypeScript**: 型安全性による開発効率の向上と実行時エラーの減少を図るため、TypeScriptを採用しました。
- **インフラのコード化**: Terraformを使用してAWSリソースをコードとして管理することで、環境の再現性や変更履歴の追跡が容易になりました。これにより、デプロイプロセスの自動化とドキュメント化も実現しています。
- **Slack API連携**: Events APIとWeb APIを組み合わせることで、メッセージイベントの取得とユーザー・チャンネル情報の取得を実現しました。セキュリティ面では署名検証を行い、不正なリクエストを排除しています。

これらの技術選定により、保守性が高く、拡張性のあるシステムを実現することができました。

## 4. 工夫した点・苦労した点

### 4.1 Slackイベント処理の最適化

Slackイベント処理における主な工夫点は以下の通りです：

1. **ボットメッセージのフィルタリング**: ボット自身のメッセージによる無限ループを防止しました。

```typescript
// ボットメッセージをスキップ（無限ループ防止）
if (messageEvent.bot_id || messageEvent.subtype === 'bot_message') {
  return {
    statusCode: 200,
    body: JSON.stringify({ success: true, message: 'Bot message ignored' })
  };
}
```

2. **エラーハンドリング**: 様々なエラーケースに対応し、Slackに適切なレスポンスを返すようにしました。これにより、Slackのイベント再送信を防止し、システムの安定性を高めています。

3. **必須フィールドの検証**: イベントデータの検証を厳密に行い、不正なデータによる処理エラーを防止しています。

### 4.2 重複メッセージ防止の仕組み（DynamoDBによる冪等性確保）

Slackイベントは、ネットワークの問題などにより重複して送信されることがあります。そのため、DynamoDBを使用して、すでに処理したイベントを記録し、重複処理を防止する仕組みを実装しました。

```typescript
export const checkAndMarkProcessed = async (eventId: string, ttlHours: number = 24): Promise<boolean> => {
  // TTL（有効期限）を計算
  const expiryTime = Math.floor(Date.now() / 1000) + (ttlHours * 60 * 60);
  
  try {
    // 条件付き書き込み - event_idが存在しない場合のみ書き込みを行う
    await dynamoDB.put({
      TableName: TABLE_NAME,
      Item: {
        event_id: eventId,
        expiry_time: expiryTime,
        processed_at: new Date().toISOString()
      },
      ConditionExpression: 'attribute_not_exists(event_id)'
    }).promise();
    
    return true; // 新規イベント
  } catch (error) {
    if (error instanceof Error && error.name === 'ConditionalCheckFailedException') {
      return false; // 既に処理済みのイベント
    }
    // その他のエラー時は安全側に倒して処理を続行
    return true;
  }
};
```

DynamoDBのTTL（Time to Live）機能を活用することで、古いレコードを自動的に削除し、データベースのサイズを管理しています。

### 4.3 S3バケットへの追記処理の実装

S3バケットへのファイル保存において、同じ日のメッセージを一つのファイルに追記していく実装を行いました：

```typescript
export const saveToS3 = async (channelName: string, messageMarkdown: string, messageDate: Date): Promise<string> => {
  // S3のキー（ファイルパス）
  const s3Key = `40_slack_memo/${safeChannelName}/${dateString}.md`;
  
  try {
    // ファイルが既に存在するか確認
    let existingContent = '';
    try {
      const existingObject = await s3.getObject({
        Bucket: bucketName,
        Key: s3Key
      }).promise();
      
      existingContent = existingObject.Body?.toString('utf-8') || '';
    } catch (error) {
      // ファイルが存在しない場合は空文字列のまま
      if (error instanceof Error && error.name !== 'NoSuchKey') {
        throw error;
      }
    }
    
    // 新しいコンテンツを追加
    const updatedContent = existingContent + safeMessageMarkdown;
    
    // S3にファイルを保存
    await s3.putObject({
      Bucket: bucketName,
      Key: s3Key,
      Body: updatedContent,
      ContentType: 'text/markdown'
    }).promise();
    
    return s3Key;
  } catch (error) {
    throw error;
  }
};
```

この実装では、まず既存ファイルの内容を取得し、新しいメッセージを追加してから再度保存しています。ただし、この方法では同時書き込みによるデータの上書きが発生する可能性があるため、より堅牢な実装方法（S3のバージョニング機能の活用や、追記専用のAPIの使用など）も検討していく必要があります。

## 5. まとめと今後の展望

### 5.1 プロジェクトの成果

このプロジェクトにより、以下のような成果が得られました：

1. **知識の自動キャプチャ**: Slackでの会話内容を自動的にObsidianで使用可能な形式で保存できるようになりました
2. **サーバーレスアーキテクチャの活用**: 管理コストを抑えつつ、スケーラブルなシステムを構築できました
3. **インフラのコード化**: Terraformを使用してインフラをコード化し、再現性と保守性を高めました
4. **セキュリティの考慮**: 署名検証や重複排除など、セキュリティと信頼性を考慮した実装を行いました

### 5.2 改善ポイントと将来の拡張可能性

今後の改善点や拡張可能性としては、以下のような項目が考えられます：

1. **Slackのリッチテキスト対応**: メンション、リンク、コードブロックなどのSlackの書式をMarkdownに適切に変換する
2. **ファイル保存に対応させる**: 画像やPDFなどをS3に保存できるようにする 
3. **AIによる要約機能**: 日々の会話を要約してダイジェストを作成する機能