# 通知設計書 23-投稿公開Slack通知

## 概要

本ドキュメントは、Ghost CMSにおける投稿公開時のSlack通知機能の設計仕様を記載する。

### 本通知の処理概要

投稿が公開された際に、設定されたSlack WebhookにRich形式（attachments付き）で通知を送信する機能である。ブログの新着投稿をチームメンバーやコミュニティにリアルタイムで知らせることができ、コンテンツマーケティングやチームコラボレーションに活用される。

**業務上の目的・背景**：コンテンツチームやマーケティングチームは、新しい投稿が公開されたことを即座に知る必要がある。Slackは多くの組織で標準的なコミュニケーションツールとして使用されており、Ghost投稿の公開通知をSlackチャンネルに自動送信することで、チームメンバーがリアルタイムで新着コンテンツを把握し、SNS共有やプロモーション活動を迅速に開始できる。

**通知の送信タイミング**：投稿（post）が公開（published）されたタイミングで`post.published`イベントが発火し、それをトリガーとして通知が送信される。ページ（page）の公開時は送信されない。

**通知の受信者**：Ghost管理画面で設定されたSlack Webhook URLに紐づくSlackチャンネルおよびそのメンバー。

**通知内容の概要**：投稿のタイトル、URL、説明文（custom_excerptまたは本文から抽出）、アイキャッチ画像、著者情報、ブログタイトル、ブログアイコンが含まれる。

**期待されるアクション**：Slackチャンネルのメンバーは通知を確認し、投稿内容の確認、SNSでの共有、フィードバックの提供などを行う。

## 通知種別

Slack通知（Incoming Webhook）

## 送信仕様

### 基本情報

| 項目 | 内容 |
|-----|------|
| 送信方式 | 非同期（イベントリスナー経由） |
| 優先度 | 中 |
| リトライ | 無し（エラーログ出力のみ） |

### 送信先決定ロジック

1. `settingsCache`から`slack_url`設定を取得
2. URLが設定されていない場合は送信をスキップ
3. URLが設定されている場合、Slack Incoming Webhook APIにPOSTリクエストを送信

## 通知テンプレート

### Slack通知の場合

| 項目 | 内容 |
|-----|------|
| ユーザー名 | 設定値（`slack_username`）またはデフォルト「Ghost」 |
| アイコン | ブログのfaviconアイコン |
| テキスト | `Notification from *{blogTitle}* :ghost:` |
| unfurl_links | `true`（URLプレビュー有効） |

### メッセージ構造

```json
{
    "text": "Notification from *Blog Title* :ghost:",
    "unfurl_links": true,
    "icon_url": "https://example.com/favicon.ico",
    "username": "Ghost",
    "attachments": [
        {
            "fallback": "Sorry, content cannot be shown.",
            "title": "Post Title",
            "title_link": "https://example.com/post-slug/",
            "author_name": "Blog Title",
            "image_url": "https://example.com/images/feature.jpg",
            "color": "#008952",
            "fields": [
                {
                    "title": "Description",
                    "value": "First three sentences of the post...",
                    "short": false
                }
            ]
        },
        {
            "fallback": "Sorry, content cannot be shown.",
            "color": "#008952",
            "thumb_url": "https://example.com/images/author.jpg",
            "fields": [
                {
                    "title": "Author",
                    "value": "<https://example.com/author/name/ | Author Name>",
                    "short": true
                }
            ],
            "footer": "Blog Title",
            "footer_icon": "https://example.com/favicon.ico",
            "ts": 1234567890
        }
    ]
}
```

### 添付ファイル

なし

## テンプレート変数

| 変数名 | 説明 | データ取得元 | 必須 |
|--------|------|-------------|-----|
| blogTitle | ブログタイトル | settingsCache.get('title') | Yes |
| post.title | 投稿タイトル | Post model | Yes |
| post.id | 投稿ID | Post model | Yes（URL生成用） |
| post.slug | 投稿スラッグ | Post model | Yes |
| post.html | 投稿本文HTML | Post model | No |
| post.custom_excerpt | カスタム抜粋 | Post model | No |
| post.feature_image | アイキャッチ画像URL | Post model | No |
| post.authors | 著者配列 | Post model（related） | No |
| slack_username | Slack表示名 | settingsCache | No（デフォルト: Ghost） |
| slack_url | Webhook URL | settingsCache | Yes |

## 送信トリガー・条件

### トリガー一覧

| トリガー種別 | トリガーイベント | 送信条件 | 説明 |
|------------|----------------|---------|------|
| モデルイベント | `post.published` | `slack_url`が設定されている | 投稿公開時に自動送信 |
| APIテスト | `slack.test` | `slack_url`が設定されている | Slack連携のテスト送信 |

### 送信抑止条件

| 条件 | 説明 |
|-----|------|
| URL未設定 | `slack_url`が空または未設定の場合 |
| ページタイプ | `post.type === 'page'`の場合は送信しない |
| デフォルト投稿 | デフォルトスラッグ（welcome, the-editor等）の投稿は送信しない |
| インポート時 | `options.importing === true`の場合は送信しない |

## 処理フロー

### 送信フロー

```mermaid
flowchart TD
    A[post.publishedイベント発火] --> B{importingフラグ?}
    B -->|true| C[終了]
    B -->|false| D[ping関数呼出]
    D --> E{slack_url設定?}
    E -->|なし| C
    E -->|あり| F{type === page?}
    F -->|Yes| C
    F -->|No| G{デフォルト投稿?}
    G -->|Yes| C
    G -->|No| H[slackDataオブジェクト構築]
    H --> I[説明文生成]
    I --> J[attachments構築]
    J --> K[Webhook POSTリクエスト]
    K --> L{送信結果}
    L -->|成功| M[終了]
    L -->|失敗| N[エラーログ出力]
    N --> M
```

## データベース参照・更新仕様

### 参照テーブル一覧

| テーブル名 | 用途 | 備考 |
|-----------|------|------|
| settings | Slack設定取得 | settingsCache経由 |
| posts | 投稿データ | イベント引数から取得 |
| users | 著者情報 | リレーション経由 |

### テーブル別参照項目詳細

#### settings（settingsCache経由）

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| value | Slack Webhook URL | `key = 'slack_url'` |
| value | Slack表示名 | `key = 'slack_username'` |
| value | ブログタイトル | `key = 'title'` |

#### posts

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| id | URL生成 | イベントトリガー |
| title | 通知タイトル | イベントトリガー |
| slug | URL生成・デフォルト判定 | イベントトリガー |
| html | 説明文抽出 | イベントトリガー |
| custom_excerpt | カスタム説明文 | イベントトリガー |
| feature_image | アイキャッチ画像 | イベントトリガー |
| type | ページ判定 | イベントトリガー |

### 更新テーブル一覧

なし（通知送信のみ）

## エラー処理

### エラーケース一覧

| エラー種別 | 発生条件 | 対処方法 |
|----------|---------|---------|
| HTTPリクエスト失敗 | Webhook URL無効、ネットワークエラー | エラーログ出力、処理継続 |
| URL不正 | Webhook URLの形式が不正 | 送信スキップ |

### リトライ仕様

| 項目 | 内容 |
|-----|------|
| リトライ回数 | 0回 |
| リトライ間隔 | N/A |
| リトライ対象エラー | N/A |

## 配信設定

### レート制限

| 項目 | 内容 |
|-----|------|
| 1分あたり上限 | なし（Slack側の制限に依存） |
| 1日あたり上限 | なし |

### 配信時間帯

制限なし（投稿公開時に即時送信）

## セキュリティ考慮事項

- Slack Webhook URLは機密情報として扱い、管理画面でのみ設定可能
- 会員限定コンテンツ（`<!--members-only-->`以降）は説明文から除外される
- 投稿URLは絶対URLとして生成され、外部からアクセス可能

## 備考

- デフォルト投稿スラッグ（welcome, the-editor, using-tags, managing-users, private-sites, advanced-markdown, themes）は通知対象外
- テスト通知機能（`slack.test`イベント）により、設定確認が可能
- attachmentsのカラーコードは`#008952`（Ghost緑）で統一

---

## コードリーディングガイド

本通知を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

Slackに送信されるペイロードの構造を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | slack.js | `ghost/core/core/server/services/slack.js` | 108-156行目、slackDataオブジェクトの構築 |

**読解のコツ**: Slack Incoming Webhook APIの仕様を踏まえ、`text`, `unfurl_links`, `icon_url`, `username`, `attachments`の各フィールドの意味を把握する。

#### Step 2: エントリーポイントを理解する

イベントリスナーの登録と処理の起点を確認する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | slack.js | `ghost/core/core/server/services/slack.js` | 192-200行目、listen()関数 |

**主要処理フロー**:
1. **193-195行目**: `post.published`イベントへのリスナー登録
2. **197-199行目**: `slack.test`イベントへのリスナー登録

#### Step 3: メイン処理ロジックを理解する

投稿データからSlack通知を生成する処理を追う。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | slack.js | `ghost/core/core/server/services/slack.js` | 48-171行目、ping()関数 |

**主要処理フロー**:
- **48-56行目**: 初期変数宣言とSlack設定取得
- **58-89行目**: 投稿プロパティの抽出と説明文生成
- **92-97行目**: URL未設定時の早期リターン
- **95-105行目**: ページ・デフォルト投稿の除外
- **107-156行目**: slackDataオブジェクト構築
- **158-170行目**: HTTPリクエスト送信とエラーハンドリング

#### Step 4: 説明文生成ロジックを理解する

本文からの説明文抽出処理を確認する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | slack.js | `ghost/core/core/server/services/slack.js` | 63-86行目 |

**主要処理フロー**:
- **63-64行目**: `custom_excerpt`優先使用
- **65-83行目**: HTML本文から説明文抽出（会員限定コンテンツ除外、HTMLタグ除去、最初の3文抽出）

### プログラム呼び出し階層図

```
Ghost起動時
    │
    └─ slack.listen() [192-200行目]
           │
           ├─ events.on('post.published', slackListener)
           │
           └─ events.on('slack.test', slackTestPing)

投稿公開時
    │
    └─ Model.onCreated/onUpdated → events.emit('post.published')
           │
           └─ slackListener(model, options) [173-184行目]
                  │
                  ├─ [importing?] return
                  │
                  └─ ping({...model.toJSON(), authors: ...}) [48-171行目]
                         │
                         ├─ getSlackSettings() [29-37行目]
                         │      └─ settingsCache.get('slack_url', 'slack_username')
                         │
                         ├─ hasPostProperties() [44-46行目]
                         │
                         ├─ urlService.getUrlByResourceId()
                         │
                         ├─ 説明文生成 [63-86行目]
                         │
                         ├─ slackData構築 [107-156行目]
                         │      │
                         │      ├─ blogIcon.getIconUrl()
                         │      │
                         │      └─ urlUtils.urlFor()
                         │
                         └─ request(slackSettings.url, {...}) [158-170行目]
```

### データフロー図

```
[入力]                    [処理]                           [出力]

Post Model ───────────▶ slackListener()
(post.published)              │
                              ▼
                        インポートチェック
                              │
                              ▼
                        ping()
                              │
settingsCache ────────▶ getSlackSettings()
                              │
                              ▼
                        プロパティ抽出
                              │
post.html ───────────▶ 説明文生成 ──────────▶ description
post.custom_excerpt         │
                              ▼
urlService ───────────▶ URL生成 ────────────▶ message (postUrl)
                              │
                              ▼
blogIcon ─────────────▶ slackData構築 ─────▶ slackData object
urlUtils                      │
                              ▼
                        request() ─────────▶ Slack Webhook API
                              │
                        [エラー時]
                              ▼
                        logging.error() ───▶ ログファイル
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| slack.js | `ghost/core/core/server/services/slack.js` | ソース | Slack通知のメインロジック |
| settings-cache | `ghost/core/core/shared/settings-cache` | ソース | Slack設定の取得 |
| url.js | `ghost/core/core/server/services/url/index.js` | ソース | 投稿URL生成 |
| blogIcon.js | `ghost/core/core/server/lib/image/blog-icon.js` | ソース | ブログアイコンURL取得 |
| events.js | `ghost/core/core/server/lib/common/events.js` | ソース | イベントシステム |
