✌️

【見落とさないSlack運用】Obsidianと連携して会話ログを毎日保存

に公開

Slackの会話ログを毎日自動でObsidianに保存する

Python + GitHub Actions + Private Repoで安全運用


0. TL;DR(要約)

  • PythonでSlackログをMarkdown化(スレッド対応・JST表示)。
  • GitHub Actionsが JST 00:30前日分(JST 0:00–24:00) を自動収集。
  • 収集結果は Privateリポジトリ(Obsidian Vault) に push。
  • Obsidianはその Vault を pull して常に最新化。
  • 機密情報は GitHub Secrets で安全管理(Slackトークン/チャンネルID/PAT)。

1. 背景とゴール

  • 目的:Slackの会話を日次でアーカイブし、Obsidianで検索・参照できるようにする。
  • 要件:無料プラン想定、Publicに会話を晒さない(Private保存)、メンテ容易
  • 設計:実行コード用 Public リポ(slack-to-obsidian)と、保存用 Private リポ(xxx-obsidian-vault)を分離。

2. 全体アーキテクチャ

Slack → (Python) → logs/YYYY-MM-DD.md

           └─ GitHub Actions(毎日 JST 00:30 実行)

                  └─ Private Repo(Obsidian Vault)

                          └─ Obsidian Git / cron で pull
  • GitHub Actions の cronUTC指定JST 00:30 = UTC 15:30
  • 取得対象は 前日(JST 0:00–24:00) に固定(0:30時点で前日を確定集計)。

3. 前提・準備

3.1 Slack 側

  • Slack Bot Token(Botユーザー+権限付与)
    • 例:channels:history, groups:history, im:history, mpim:history, users:read(運用に合わせ最小権限)。

3.2 GitHub リポ構成

  • Publicslack-to-obsidian(PythonスクリプトとActionsを置く)
  • Privatexxx-obsidian-vault(Obsidian Vaultとして運用。logs/ にMD保存)

3.3 GitHub Secrets(Public側=slack-to-obsidian に登録)

Secret名 内容
SLACK_BOT_TOKEN Base64化した Slack Bot Token
SLACK_CHANNEL_IDS 取得対象CHIDのカンマ区切り(例:C01...,C02...
ENCODED_PAT_FOR_PUSH Base64化した Personal Access Token(classic / repo スコープ)
PRIVATE_REPO_SLUG YourName/xxx-obsidian-vault

4. ローカル実装(Python)

4.1 依存関係と .env

requirements.txt

slack_sdk==3.27.1
python-dotenv==1.0.1

.env(ローカル検証用の例)

SLACK_BOT_TOKEN=(Base64のSlack Bot Token)
SLACK_CHANNEL_IDS=C01234567,C07654321

4.2 収集スクリプト(前日/JST・スレッド対応・ユーザー名解決)

slack-to-obsidian.py は長いため別ファイルで管理(詳細は省略)


5. Slackから会話を取得してくるPythonコード

import os
import base64
from datetime import datetime, timedelta, timezone
from pathlib import Path
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from dotenv import load_dotenv

# タイムゾーン定義
JST = timezone(timedelta(hours=9))
UTC = timezone.utc

load_dotenv()

# SlackチャンネルID一覧
channel_ids_str = os.getenv("SLACK_CHANNEL_IDS")
if not channel_ids_str:
    raise ValueError("SLACK_CHANNEL_IDS not found in environment variables")

CHANNEL_IDS = [cid.strip() for cid in channel_ids_str.split(",")]

# 保存ディレクトリ
OUTPUT_DIR = Path("logs")
OUTPUT_DIR.mkdir(exist_ok=True)

# Botトークン読み込み
token_encoded = os.getenv("SLACK_BOT_TOKEN")
if not token_encoded:
    raise ValueError("SLACK_BOT_TOKEN not found")
SLACK_TOKEN = base64.b64decode(token_encoded).decode("utf-8")
client = WebClient(token=SLACK_TOKEN)

# 実行日(JST)の前日を対象にする
yesterday_jst_date = datetime.now(JST).date() - timedelta(days=1)
today_jst = datetime(
    year=yesterday_jst_date.year,
    month=yesterday_jst_date.month,
    day=yesterday_jst_date.day,
    tzinfo=JST,
)

# today_jst = datetime(2025, 7, 31, tzinfo=JST)  # ← 任意に変更

log_date_str = today_jst.strftime("%Y-%m-%d")

# JSTの0:00〜24:00 → UTCタイムスタンプに変換
start_jst = datetime(today_jst.year, today_jst.month, today_jst.day, 0, 0, 0, tzinfo=JST)
end_jst = start_jst + timedelta(days=1)
start_ts = start_jst.astimezone(UTC).timestamp()
end_ts = end_jst.astimezone(UTC).timestamp()

# メッセージをMarkdown形式に変換
def format_message(ts, user, text, indent_level=0):
    time_str = datetime.fromtimestamp(float(ts), JST).strftime("%H:%M")
    indent = "    " * indent_level
    return f"{indent}- **{time_str}** [@{user}]: {text.strip()}"

# ユーザーIDからユーザー名を取得
user_cache = {}
def get_user_name(user_id):
    if user_id in user_cache:
        return user_cache[user_id]
    try:
        user_info = client.users_info(user=user_id)
        name = user_info["user"]["real_name"] or user_info["user"]["name"]
        user_cache[user_id] = name
        return name
    except:
        return user_id

# スレッドの返信を取得
def fetch_thread_replies(channel_id, thread_ts):
    replies = []
    try:
        thread_response = client.conversations_replies(
            channel=channel_id,
            ts=thread_ts,
            limit=50
        )
        for reply in thread_response["messages"][1:]:  # 最初は親なので除外
            if "subtype" in reply:
                continue
            user = get_user_name(reply.get("user", "unknown"))
            text = reply.get("text", "").replace("\n", " ")
            replies.append(format_message(reply["ts"], user, text, indent_level=1))
    except:
        pass
    return replies

# チャンネルごとに取得
md_lines = [f"# Slackログ({log_date_str}\n"]
for channel_id in CHANNEL_IDS:
    try:
        channel_info = client.conversations_info(channel=channel_id)
        channel_name = channel_info["channel"]["name"]
        md_lines.append(f"\n## #{channel_name}\n")

        result = client.conversations_history(
            channel=channel_id,
            oldest=start_ts,
            latest=end_ts,
            inclusive=True,
            limit=1000
        )

        for message in reversed(result["messages"]):
            if "subtype" in message:
                continue

            user = get_user_name(message.get("user", "unknown"))
            text = message.get("text", "").replace("\n", " ")
            formatted = format_message(message["ts"], user, text)
            md_lines.append(formatted)

            # スレッドがある場合、返信を取得
            if "thread_ts" in message and message["thread_ts"] == message["ts"]:
                replies = fetch_thread_replies(channel_id, message["ts"])
                md_lines.extend(replies)

    except SlackApiError as e:
        md_lines.append(f"\n> Error fetching messages for channel {channel_id}: {e}\n")

# 保存
output_file = OUTPUT_DIR / f"{log_date_str}.md"
with open(output_file, "w", encoding="utf-8") as f:
    f.write("\n".join(md_lines))

print(f"Saved: {output_file}")

6. GitHub Actions(収集・Private Repoへpush)

.github/workflows/daily-log.yml

name: Daily Slack Log to Obsidian

on:
  schedule:
    - cron: '30 15 * * *'  # JST 00:30(=UTC 15:30)
  workflow_dispatch:       # 手動実行も可

jobs:
  run-script:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout source repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run Slack log script
        run: python slack-to-obsidian.py
        env:
            SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
            SLACK_CHANNEL_IDS: ${{ secrets.SLACK_CHANNEL_IDS }}

      - name: Clone private Obsidian repo (no file writes; PAT kept in shell)
        run: |
          PAT=$(echo "${{ secrets.ENCODED_PAT_FOR_PUSH }}" | base64 -d)
          git clone "https://${PAT}@github.com/${{ secrets.PRIVATE_REPO_SLUG }}.git" private-vault

      - name: Copy logs to private repo
        run: |
          mkdir -p private-vault/logs
          cp -r logs/*.md private-vault/logs/ || true

      - name: Commit and push logs
        run: |
          cd private-vault
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add logs/
          git commit -m "Add Slack logs for $(date '+%Y-%m-%d')" || echo "No changes to commit"
          git push

7. Obsidian設定(同期)

7.1 Vault と Private Repo の紐付け

  1. Private Repo(例:xxx-obsidian-vault)をローカルにクローン(~/Documents/obsidian-vault など)。
  2. Obsidian → Open folder as vault でそのフォルダを開く。

7.2 Obsidian Git プラグイン

  • Community Plugins → Obsidian Git をインストール・有効化。
  • 設定例
    • Auto pull on startup:ON(起動時にpull)
    • Auto pull interval:任意(例:10〜30分)
  • さらに確実にするなら、Macの cron / launchd でローカルの git pull を 1:00 などに自動化(任意)。

8. まとめ

  • 時刻設計(JST/UTC)Secrets管理 が肝。
  • 実行環境(Public)と保存環境(Private)を分離して漏洩リスクを最小化
  • これで Slack → Markdown → Private Vault → Obsidian毎日自動で回る。
  • 拡張(ページネーション・添付保存・整形)も容易に追加可能。

9. 最後に

私の作業ログをAIにインプットしてもらい書いた記事になります。
もし、分からないところ、躓いたポイントがあったら教えていただけるとありがたいです!

Discussion