🤖

Toggl/GitHub/AI連携でチームの生産性をブーストする多機能Bot

に公開

ChatGPT Image 2025年4月10日 08_08_26.png

目次

  • はじめに
  • 機能紹介
  • コード紹介
  • その他の機能
  • 開発を通じて得られた知見
  • 感想
  • 参考文献

はじめに

チーム内のコミュニケーションとタスク管理の効率化を目指して開発した多機能Discord Botを開発したので、一部をご紹介します!

  • 日々の進捗報告の標準化
  • 時間管理の自動化とGitHubとの連携
  • AIを活用した勤怠評価や提案機能
    d939aaa1-6c65-4cae-9a51-482edf8163f7.png

機能紹介

1. 進捗報告の自動処理・提案

ChatGPT Image 2025年4月10日 09_02_30.png

機能

  1. AIを使った内容と推察とルールベースで適切なフォーマット提案:
    ❗️標準フォーマットに合致するかをチェックし、内容から報告タイプ(着手/完了など)やタスク名、参考情報などを補足
    -- ユーザーからのリアクション (👍/👎) で精度向上

  2. Google Driveと連携した柔軟なテンプレート管理
    ❗️AIが、与えられた知識を元にチームの運用に合わせた内容へと変更可能
    -- 進捗報告のテンプレートやルールは、Google Driveドキュメントまたは用意したファイルから読み込むことができます


2. Togglによる自動勤怠管理

ChatGPT Image 2025年4月10日 09_12_22.png

https://toggl.com/

https://qiita.com/makky_tyuyan/items/66e98295c9f5d841580b

機能

  1. 進捗報告(着手/完了/休憩/現在状況)と勤怠報告の連動
    ❗️チャンネル内での勤怠報告を行うと、別アプリ/タブから操作せずとも自動で開始・停止します。

  2. リマインダー機能
    ❗️指定した一定間以上継続しているタスクや、着手報告時に記録された想定時間を超過したタスクについて、ユーザーや管理者にDMでリマインド
    -- 適切な進捗報告テンプレートを提示します

  3. 簡単連携
    ❗️コマンドを使って、TogglのAPIキーとワークスペースIDを登録するだけで容易
    -- レポート生成も可能


3. GitHub連携と評価機能

5530f454-aa4b-43bd-834b-247f3aea88c9.png

機能

  1. 作業ログの自動評価
    ❗️AIがコミットやマージ履歴などから内容を読み取り、あらかじめ定義された評価項目に沿ってスコア
    -- Drive上に用意したルール文書またはGitHub Issueに記載された過去の内容を基準
  2. 個別FBによる、再評価
    ❗️フィードバックを元に再評価することも可能。改善のループを通じて、開発品質の継続的な向上
  3. 進捗報告の自動コメント
    ❗️チャンネルで報告されたメッセージにGithubの情報が含まれている場合、対応するレポジトリに自動でコメントを投稿します。
    -- コメントの内容は、報告タイプに応じて自動整形されます。

コード紹介

🔧 使っている主な外部技術

  1. Discord_bot.py
    • メッセージ受信、リアクション、DM送信
  2. Toggl API
    • タイマーの開始・停止・作業時間の取得
  3. Google Drive API
    • 規則の取得, 進捗の保存/共有先として選択肢
  4. ChatGPT API
    • メッセージフォーマットのAI提案生成

進捗報告をサポートするBotの処理フロー

# discord_bot.py
async def handle_progress_report(message: discord.Message):
    """進捗報告メッセージのハンドラー"""
    user_id = str(message.author.id)

    # ... (Toggl/GitHubクライアント準備) ...

    # デフォルトでGoogle Driveを使用
    use_google_drive = True
    if "--json" in message.content.lower() or "--local" in message.content.lower():
         use_google_drive = False
         # フラグをメッセージから削除
         message.content = message.content.replace("--json", "").replace

    # 進捗報告チャンネルかどうかの判定
    is_progress_channel = str(message.channel.id) == PROGRESS_CHANNEL_ID
    # ルール確認はルール用ドキュメントで行う (for_toggl=False)
    for_toggl = False 

    logger.info(f"進捗報告処理開始: ユーザー={message.author.name}, チャンネル={message.channel.name}, toggl_client有={toggl_client is not None}, github_client有={github_client is not None}")

    try:
        # progress_handler.py の関数を呼び出し
        await process_progress_report(message, toggl_client, github_client, use_google_drive=use_google_drive, for_toggl=for_toggl)
        logger.info(f"進捗報告処理完了: ユーザー={message.author.name}")
    except Exception as e:
        logger.error(f"進捗報告処理中の例外: {str(e)}")
        await message.channel.send(f"⚠️ 進捗報告処理中にエラーが発生しました: {str(e)}")
# progress_handler.py
async def handle_progress_message(message: discord.Message) -> None:
    """進捗報告メッセージを処理する"""
    # ... (データ読み込み) ...

    # メッセージを解析
    report_data = parse_progress_report(message.content, user_id)

    if report_data.get("is_standard_format", False):
        # 標準フォーマットの場合の処理 (リアクション追加、履歴更新)
        await message.add_reaction(CHECK_EMOJI)
        update_user_history(...)
        logger.info(f"正常な進捗報告: {report_data['type']} by {message.author.name}")
        # ここで process_progress_report を呼び出すフローが必要(discord_bot.py側に実装)
    else:
        # 非標準フォーマットの場合の処理 (提案生成、リアクション待ち)
        await message.add_reaction(THINKING_EMOJI)
        suggestion = await generate_ai_suggestion(message, message.content, progress_data) # AI提案
        if not suggestion:
            # ルールベース提案
            _, suggestion = suggest_correct_format(message.content, user_id) 

        if suggestion:
             suggestion_msg = await message.channel.send(f"{message.author.mention} ... 提案メッセージ ...")
             await suggestion_msg.add_reaction("👍")
             await suggestion_msg.add_reaction("👎")
             # 提案情報を保存
             progress_data[str(suggestion_msg.id)] = { ... }
             save_progress_data(progress_data)
        else:
             await message.add_reaction(ERROR_EMOJI)
             await message.channel.send(...)

async def handle_reaction_add(reaction: discord.Reaction, user: discord.User) -> None:
    """リアクション追加時の処理(進捗報告の提案に対する応答)"""
    # ... (Bot自身のリアクションや他ユーザーのリアクションは無視) ...

    progress_data = load_progress_data()
    suggestion_data = progress_data.get(str(message.id))

    if not suggestion_data: return # 提案データなし

    # ... (元のメッセージ情報、Toggl/GitHubクライアント取得) ...

    if emoji == "👍": # 提案受け入れ
        # 提案されたフォーマットで新しいメッセージを送信
        final_message_to_process = await channel.send(suggested_format, reference=original_message)
        await message.edit(content="処理しました。") 
    elif emoji == "👎": # 提案拒否
         # 元のメッセージを処理対象とする
        final_message_to_process = original_message
        await message.edit(content="元の投稿内容を処理します。") 

    if final_message_to_process:
        # process_progress_report を呼び出して連携処理実行 (discord_bot.py側で)
         await process_progress_report(final_message_to_process, toggl_client, github_client, ...)

    # 関連データを追加と削除
    if emoji in ["👍", "👎"] and str(message.id) in progress_data:
        del progress_data[str(message.id)]
        save_progress_data(progress_data)

💡全体構造

  1. 内容を解析し、
  2. 標準フォーマットならリアクションや履歴更新、
  3. 非標準フォーマットならAI/ルールベースの提案を返す、
  4. リアクション(👍/👎)に応じて、最終的にレポート機能を実行する
    という流れで動作します。

image.png


TooglとのAPIとのやりとり

TogglとDiscordを連携させて「作業時間の計測・監視・通知」を行うボットの実装方法

# discord_bot.py
class TogglClient:
    # ... (初期化、_make_request) ...
    # タイマー開始
    async def start_time_entry(self, project_name: str, description: str = "", task_id: str = None) -> dict | None:
        project_id = await self.get_project_id_by_name(project_name)
        if not project_id:
            raise ValueError(f"プロジェクト '{project_name}' が見つかりません")
            # 事前に確認を行うことで、存在しないプロジェクトへの登録を防ぐ
        tags = []
        if task_id:
            clean_task_id = task_id.strip('#')
            if clean_task_id.isdigit():
                tags.append(f"issue-{clean_task_id}") # Issue番号をタグに設定

        payload = {
            "created_with": "Discord Bot",
            "workspace_id": int(self.workspace_id),
            "description": description or project_name,
            "project_id": project_id,
            "start": datetime.now(pytz.utc).isoformat(),
            "duration": -1, # 計測開始を示す
            "tags": tags if tags else None
        }
        return await self._make_request(
            "POST",
            f"{self.BASE_URL}/workspaces/{self.workspace_id}/time_entries",
            json=payload
        )
    # タイマー停止
    async def stop_current_entry(self) -> dict | None:
        current = await self.get_current_entry()
        if current:
            return await self._make_request(
                "PATCH",
                f"{self.BASE_URL}/workspaces/{self.workspace_id}/time_entries/{current['id']}/stop"
            )
        return None
    # レポート機能
    async def get_report(self, start_date: datetime, end_date: datetime) -> list:
         # ... (Reports API呼び出し) ...

# jsonによる、進捗情報の管理
@tasks.loop(seconds=REMIND_INTERVAL) # tasks.loopに変更
async def check_long_entries():
    # ... (ユーザーデータ読み込み、TogglClient初期化) ...
    for discord_id, creds in users.items():
        toggl = TogglClient(creds["api_key"], creds["workspace_id"])
        entry = await toggl.get_current_entry()
        if entry and entry.get("duration", 0) < 0: # 実行中
            start_time = datetime.fromisoformat(entry["start"]).replace(tzinfo=pytz.utc)
            elapsed = (datetime.now(pytz.utc) - start_time).total_seconds()

            # 想定時間をprogress_data.jsonから取得
            estimated_time_minutes = get_estimated_time_from_data(...) 

            # 長時間リマインド
            if elapsed > REMIND_THRESHOLD:
                 if needs_remind(entry_id, last_reminded):
                    user = await client.fetch_user(int(discord_id))
                    await user.send(f"⚠️ 長時間実行リマインド...")
                    last_reminded[entry_id] = datetime.now(pytz.utc)

            # 想定時間超過リマインド
            if estimated_time_minutes and elapsed > (estimated_time_minutes * 60):
                 if needs_remind(entry_id, last_reminded):
                     user = await client.fetch_user(int(discord_id))
                     await user.send(f"⏰ 想定時間超過リマインド...")
                     last_reminded[entry_id] = datetime.now(pytz.utc)
    # await asyncio.sleep(REMIND_INTERVAL) # tasks.loopが間隔を管理

👀補足

自己管理・タスク報告を効率化する仕組みは、学生や開発チーム向けにかなり実用的AI提案と組み合わせることで、Slackでは真似できない 進捗文化の自動化を狙っています


その他の機能

Togglには必要な情報のみを指定されたフォーマットで投稿

Toggl API v9の仕様変更:
調査結果1(Togglコミュニティ)から、時間エントリ作成時にproject_nameではなくproject_idが必要!

パラメータdurationが-1に設定されているか確認が必要

def has_github_issue_url(content: str) -> bool:
    """メッセージ内にGitHub issue URLが含まれているかチェック"""
    url_pattern = re.compile(r'https?://(?:www\.)?github\.com/[^/]+/[^/]+/issues/\d+')
    return bool(url_pattern.search(content))

def get_github_issue_urls(issue_numbers: List[str], repo_url: str) -> List[str]:
    """GitHubのIssue番号からURLのリストを生成する"""
    if not repo_url or not issue_numbers:
        return []

提案された内容に対して、再度botが反応しないように、自分自身のリアクションに対しては無視する。

    @bot.event
    async def on_reaction_add(reaction, user):
        # 自分自身のリアクションは無視
        if user.bot:
            return
        
        # 進捗報告関連のリアクション処理
        await handle_reaction_add(reaction, user)

botへのアクセス制御

# discord_bot.py
ALLOWED_ROLES = {
    "register": ["Admin"], # Togglコマンドは 'Admin' ロールを持つ人のみ
    "github": ["dev"],
    # ... 他のコマンドタイプ ...
    "ranking": [], # 利用ランキングは誰でもOK
}

def has_required_roles(member, command_type: str) -> bool:
    if command_type not in ALLOWED_ROLES: return True # 設定なければ誰でもOK
    if not isinstance(member, discord.Member): # DMの場合
        return not ALLOWED_ROLES[command_type]

    user_roles = [role.name for role in member.roles]
    for allowed_role in ALLOWED_ROLES[command_type]:
        if allowed_role in user_roles:
            return True
    return False

紹介した以外で実装した、必須の事項

  • データ処理
    • タイムゾーン調整: Togglのデフォルトタイムゾーン(UTC)とユーザーローカルタイムの変換
    • 未分類タスク処理:プロジェクト未設定エントリーへのデフォルト値割当
    • 並べ替えアルゴリズム: 開始時間(start字段)をキーとしたソート実装
  • 通知機能
    • 切り忘れ防止機能の拡張インターバル時間の追加
    • 投稿内容をベースにした分類処理
    • 必要項目が記載されているかチェック不足情報の指摘メッセージ生成
      • 柔軟な記述(表現ゆれ)にも対応できるようにする(NLP・ChatGPT活用)
      • 過去の投稿履歴に基づく補完提案
      • 進捗報告をGitHub Issuesにコメントとして投稿
  • データ活用
    • GitHub上のIssueの作業履歴のトラッキング
    • 変更内容と時間のから作業評価
    • 同一ユーザーの過去の作業履歴の参照
    • 先行事例や蓄積されたナレッジから、コーディングの提案

image.png


開発を通じて得られた知見

1. AzureWebAppからbackendのAPIを建てるときに、Applicationエラーになる

結論:FTPでファイルをアップロードして、sshで入って直接コマンド打つ

nohup python discord_bot.py > discord_bot.log 2>&1 &
  • コマンドの各部分の説明
    • nohup

      • ターミナルを切断してもプロセスを継続して実行するためのコマンドです。
    • python discord_bot.py

      • 実行するPythonスクリプトです。ローカルで python discord_bot.py で動作していたスクリプトをそのまま使用できます
    • discord_bot.log

      • スクリプトの標準出力(stdout)を discord_bot.log というファイルにリダイレクトします。ログ出力先はお好みのファイル名に変更可能です
    • 2>&1

      • エラー出力(stderr)も標準出力と同じファイルにリダイレクトしています。これにより、全てのログが1つのファイルにまとめられます
    • &

      • コマンドをバックグラウンドで実行するための指定です。これにより、実行後にターミナルがすぐに使える状態となります

2. ローカルで問題なく動いているが、Azure App Service on Windows の場合Application Errorになる。

理由は複数考えられるが...

結論:reload=True が原因でした!

reload=os.getenv("DEBUG", "false").lower() == "true"

この行により、Azure 環境で環境変数 DEBUG=true が設定されていた場合に reload=True となり、それが Windows 環境ではうまく動作しません。


🔍 reload=True の副作用(Azure App Service on Windows の場合)

  • reload=True は、watchdog というモジュールを使ってファイルの変更を監視し、再起動する開発用機能です。
  • プロセスを再起動する必要があるため、内部的に子プロセス(multiprocessing)を使います。
  • Windows 環境では __name__ == "__main__" ブロックの外で uvicorn.run() を使うと 子プロセスがうまく起動せず、アプリケーションエラー(タイムアウトやプロセス停止)になります。
  • Azure Web Apps の Windows コンテナでは、reload=True を使うと子プロセス起動がブロックされる場合があり、アプリが落ちる。

動くコード(reload=False の安全設計)

if __name__ == '__main__':
    import uvicorn
    port = int(os.environ.get("PORT", 8000))
    uvicorn.run("main:app", host="0.0.0.0", port=port)

このようにすれば、reload を明示的に使わず、本番環境にふさわしいシングルプロセスで起動するため、Azure でも問題が起きません。


🛠 対策・改善案

1. 環境変数 DEBUG を本番環境で false にする

DEBUG=false

2. reload フラグの指定を避ける or OS 判定を入れる

import platform
reload_flag = os.getenv("DEBUG", "false").lower() == "true" and platform.system() != "Windows"

uvicorn.run(
    "main:app",
    host="0.0.0.0",
    port=int(os.getenv("PORT", "8000")),
    reload=reload_flag
)

💡補足

Azure Linux プランであればこの問題は発生しにくいですが、Windows 環境では開発用の reload=True を避けた方が無難です。


制限がある中で、同期・非同期の処理を並列でどう行えば良いか?

結論:レートリミット回避にはトークンバケットアルゴリズムが有効

image.png

セッションライフサイクルの適切な管理が安定性の要です。DNSキャッシュの導入で初期接続時間を最大 30% 短縮可能!
AWS c5.xlargeインスタンスでの比較(1万リクエスト):

方式 時間 メモリ使用量 成功率
同期(requests) 182秒 1.2GB 99.8%
aiohttp(基本) 23秒 680MB 98.5%
aiohttp(最適化後) 15秒 450MB 99.9%

感想

  1. 外部APIを利用する際は?
    公式ドキュメントを熟読することはもちろん
    タイムアウト設定、指数バックオフを用いたリトライ戦略、想定されるエラーコードごとのハンドリング、レート制限への対応が不可欠であることを再認識しました。
  2. Discord Bot開発の奥深さ
    単純なコマンド応答だけでなく、メッセージ解析、リアクションイベントのハンドリング、ロールベースの権限管理、バックグラウンドタスクの実行など、Discord APIが提供する豊富な機能を活用することで、ユーザー体験を大きく向上させられることを実感しました。

参考文献

Quita - toggleとtoggle!の使い方

https://qiita.com/TK_WebSE/items/82f6752a26167de0a325

Azure App serviceでカスタム404ページを稼働させる。

https://learn.microsoft.com/en-us/answers/questions/1826974/how-to-fix-404-not-found-nginx-1-26-1-on-app-servi

Google Drive API v3 JavaでDrive APIを使う

https://qiita.com/doran/items/15b2c59adb410ddeeb8a

GithubPAT関連

https://qiita.com/ko-he-8/items/29d72226b93065c676ff

https://qiita.com/pokapu/items/51d4b028b7c843a55929

Togglとのやりとり関連

https://github.com/toggl/toggl_api_docs/blob/master/reports.md

http://docs.aiohttp.org

https://brightdata.jp/blog/web-scraping-with-aiohttp

https://jp-seemore.com/iot/python/29545/

https://qiita.com/TK_WebSE/items/82f6752a26167de0a325

Discussion